FastAPI + Pydantic v2 validators, discriminated unions and serializers: working versions (tested 2026-10-15)
Pydantic v2 patterns in FastAPI that agents often get wrong: field_validator, model_validator, discriminated unions, computed_field and response_model filtering.
passed4 passed · 0 failed2026-10-15 13:22 UTC- Status
- passed
- Last passed
- 2026-10-15 13:22 UTC
- Tests
- 4 / 4
- Language
- Python
- Runtime
- Python 3.13.16
uv venv -q .venv && uv pip install -q --python .venv/bin/python -r requirements.txt .venv/bin/python -m pytest -q
Python 3.13.16 linux 6.17.0-1022-azure x64 runner: github-actions 38741369695
The code
Exactly the files that passed. Your agent can pull them with get_recipe("fastapi-pydantic-v2-models") (install), or see them on GitHub.
fastapi==0.143.1 pydantic==2.14.1 httpx==0.28.1 pytest==9.1.1
from datetime import date
from typing import Annotated, Literal, Union
from fastapi import FastAPI
from pydantic import BaseModel, ConfigDict, Field, computed_field, field_validator, model_validator
class Card(BaseModel):
kind: Literal["card"]
number: str = Field(pattern=r"^\d{16}$")
class Bank(BaseModel):
kind: Literal["bank"]
iban: str
Payment = Annotated[Union[Card, Bank], Field(discriminator="kind")]
class OrderIn(BaseModel):
model_config = ConfigDict(str_strip_whitespace=True, extra="forbid")
email: str
quantity: int = Field(gt=0, le=100)
unit_price: float = Field(gt=0)
ship_on: date | None = None
payment: Payment
@field_validator("email")
@classmethod
def lower_email(cls, v: str) -> str:
if "@" not in v:
raise ValueError("not an email")
return v.lower()
@model_validator(mode="after")
def bulk_needs_bank(self):
if self.quantity > 50 and self.payment.kind != "bank":
raise ValueError("orders over 50 units must pay by bank transfer")
return self
class OrderOut(BaseModel):
email: str
quantity: int
unit_price: float
payment_kind: str
@computed_field
@property
def total(self) -> float:
return round(self.quantity * self.unit_price, 2)
app = FastAPI()
@app.post("/orders", response_model=OrderOut, status_code=201)
def create_order(order: OrderIn):
return OrderOut(email=order.email, quantity=order.quantity, unit_price=order.unit_price, payment_kind=order.payment.kind)
from fastapi.testclient import TestClient
from app.main import app, OrderIn
client = TestClient(app)
card = {"kind": "card", "number": "4242424242424242"}
def test_valid_order_with_computed_total():
r = client.post("/orders", json={"email": " ADA@Example.com ", "quantity": 3, "unit_price": 9.99, "payment": card})
assert r.status_code == 201
assert r.json() == {"email": "ada@example.com", "quantity": 3, "unit_price": 9.99, "payment_kind": "card", "total": 29.97}
def test_discriminated_union_error_points_at_right_branch():
r = client.post("/orders", json={"email": "a@b.c", "quantity": 1, "unit_price": 1, "payment": {"kind": "card", "number": "1"}})
assert r.status_code == 422
assert r.json()["detail"][0]["loc"][-2:] == ["card", "number"]
def test_model_validator_and_extra_forbid():
r = client.post("/orders", json={"email": "a@b.c", "quantity": 60, "unit_price": 1, "payment": card})
assert r.status_code == 422 and "bank transfer" in r.text
r = client.post("/orders", json={"email": "a@b.c", "quantity": 1, "unit_price": 1, "payment": card, "coupon": "x"})
assert r.status_code == 422
def test_model_validate_json_directly():
o = OrderIn.model_validate_json('{"email":"x@y.z","quantity":1,"unit_price":2,"payment":{"kind":"bank","iban":"DE00"}}')
assert o.payment.kind == "bank"
[pytest] pythonpath = .
Connect to ProvenPin
Add the free MCP server so your agent can pull this recipe and others like it. Same setup as the docs.
claude mcp add --transport http provenpin https://mcp.provenpin.com/mcp
{ "mcpServers": { "provenpin": { "url": "https://mcp.provenpin.com/mcp" } } }{ "servers": { "provenpin": { "type": "http", "url": "https://mcp.provenpin.com/mcp" } } }{ "mcpServers": { "provenpin": { "serverUrl": "https://mcp.provenpin.com/mcp" } } }{ "mcpServers": { "provenpin": { "type": "streamableHttp", "url": "https://mcp.provenpin.com/mcp" } } }Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.
Related stacks
FastAPI background tasks + dependency overrides
FastAPI BackgroundTasks for post-response work, with dependency_overrides to swap services in tests.
FastAPI endpoint with Pydantic v2 discriminated unions and Annotated validators
Accept a tagged union of payment methods with Field(discriminator), add Annotated validators, and test the exact 422 error shapes offline with TestClient.
FastAPI + PyJWT bearer auth
OAuth2 password flow in FastAPI issuing PyJWT tokens, with a current-user dependency guarding routes.
FastAPI + SQLAlchemy 2 CRUD with dependency-injected sessions
FastAPI routes backed by SQLAlchemy 2.x typed models, a per-request session dependency, and TestClient tests on SQLite.