FastAPI endpoint with Pydantic v2 discriminated unions and Annotated validators: working versions (tested 2026-10-15)
Accept a tagged union of payment methods with Field(discriminator), add Annotated validators, and test the exact 422 error shapes offline with TestClient.
passed5 passed · 0 failed2026-10-15 13:24 UTC- Status
- passed
- Last passed
- 2026-10-15 13:24 UTC
- Tests
- 5 / 5
- 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("pydantic-v2-discriminated-unions-fastapi") (install), or see them on GitHub.
fastapi==0.143.1 pydantic==2.14.1 httpx==0.28.1 pytest==9.1.1
from typing import Annotated, Literal
from fastapi import FastAPI
from pydantic import AfterValidator, BaseModel, Field
app = FastAPI()
def luhn(v: str) -> str:
total = 0
for i, ch in enumerate(reversed(v)):
d = int(ch)
if i % 2: # double every second digit from the right
d = d * 2 - 9 if d > 4 else d * 2
total += d
if total % 10:
raise ValueError("Luhn checksum failed")
return v
class Card(BaseModel):
kind: Literal["card"]
number: Annotated[str, Field(pattern=r"^\d{12,19}$"), AfterValidator(luhn)]
class Bank(BaseModel):
kind: Literal["bank"]
iban: Annotated[str, Field(min_length=15, max_length=34)]
# The discriminator must be a Literal on every branch; a plain Union
# would try each branch and produce noisier errors.
Payment = Annotated[Card | Bank, Field(discriminator="kind")]
class Order(BaseModel):
amount: Annotated[int, Field(gt=0)]
payment: Payment
@app.post("/orders")
def create_order(order: Order):
return {"ok": True, "kind": order.payment.kind}
from fastapi.testclient import TestClient
from app import app
client = TestClient(app)
def card(**overrides):
return {"kind": "card", "number": "4111111111111111", **overrides}
def test_valid_card_and_bank():
r = client.post("/orders", json={"amount": 500, "payment": card()})
assert r.status_code == 200
assert r.json() == {"ok": True, "kind": "card"}
bank = {"kind": "bank", "iban": "DE89370400440532013000"}
r = client.post("/orders", json={"amount": 5, "payment": bank})
assert r.json()["kind"] == "bank"
def test_unknown_tag_is_422_union_tag_invalid():
r = client.post("/orders", json={"amount": 1, "payment": {"kind": "crypto"}})
assert r.status_code == 422
err = r.json()["detail"][0]
assert err["type"] == "union_tag_invalid"
assert err["loc"][:2] == ["body", "payment"]
def test_missing_discriminator_is_union_tag_not_found():
r = client.post("/orders", json={"amount": 1, "payment": {"number": "4111111111111111"}})
assert r.status_code == 422
assert r.json()["detail"][0]["type"] == "union_tag_not_found"
def test_branch_field_error_loc_includes_tag():
r = client.post("/orders", json={"amount": 1, "payment": card(number="41x1")})
err = r.json()["detail"][0]
assert err["loc"] == ["body", "payment", "card", "number"]
assert err["type"] == "string_pattern_mismatch"
def test_annotated_luhn_validator_runs_after_pattern():
r = client.post("/orders", json={"amount": 1, "payment": card(number="4111111111111112")})
err = r.json()["detail"][0]
assert err["type"] == "value_error"
assert "Luhn" in err["msg"]
[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 + Pydantic v2 validators, discriminated unions and serializers
Pydantic v2 patterns in FastAPI that agents often get wrong: field_validator, model_validator, discriminated unions, computed_field and response_model filtering.
FastAPI background tasks + dependency overrides
FastAPI BackgroundTasks for post-response work, with dependency_overrides to swap services in tests.
FastAPI config via pydantic-settings as a cached dependency
Load config from env vars with pydantic-settings, inject it into FastAPI routes through a cached dependency, and test it offline with monkeypatch and dependency_overrides.
FastAPI endpoints rate limited with slowapi, tested with TestClient
Add per-route and per-API-key rate limits to FastAPI with slowapi, then test 429 behavior offline with TestClient and the in-memory store.