ProvenPin Install

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:29 UTC
Status
passed
Last passed
2026-10-15 13:29 UTC
Tests
4 / 4
Language
Python
Runtime
Python 3.13.16
pinned versions4
fastapi@0.143.1pydantic@2.14.1httpx@0.28.1pytest@9.1.1
run it
uv venv -q .venv && uv pip install -q --python .venv/bin/python -r requirements.txt
.venv/bin/python -m pytest -q
test environment
Python 3.13.16
linux 6.17.0-1022-azure
x64
runner: github-actions 38742087302

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.

requirements.txt
fastapi==0.143.1
pydantic==2.14.1
httpx==0.28.1
pytest==9.1.1
app/__init__.py
app/main.py
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)
tests/test_orders.py
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": " [email protected] ", "quantity": 3, "unit_price": 9.99, "payment": card})
    assert r.status_code == 201
    assert r.json() == {"email": "[email protected]", "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": "[email protected]", "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": "[email protected]", "quantity": 60, "unit_price": 1, "payment": card})
    assert r.status_code == 422 and "bank transfer" in r.text
    r = client.post("/orders", json={"email": "[email protected]", "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":"[email protected]","quantity":1,"unit_price":2,"payment":{"kind":"bank","iban":"DE00"}}')
    assert o.payment.kind == "bank"
pytest.ini
[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.

terminal
claude mcp add --transport http provenpin https://mcp.provenpin.com/mcp

Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.