python 40 lines · 7 steps

Cross-field order validation in FastAPI

A Pydantic model_validator recomputes an order total from its line items so bad payloads are rejected before any business logic runs.

Explained by highlit
1from decimal import Decimal
2from typing import Annotated
3 
4from fastapi import APIRouter, status
5from pydantic import BaseModel, Field, model_validator
6 
7router = APIRouter(prefix="/orders", tags=["orders"])
8 
9 
10class LineItem(BaseModel):
11 sku: str
12 quantity: Annotated[int, Field(gt=0)]
13 unit_price: Annotated[Decimal, Field(gt=0, decimal_places=2)]
14 
15 @property
16 def subtotal(self) -> Decimal:
17 return self.unit_price * self.quantity
18 
19 
20class OrderCreate(BaseModel):
21 customer_id: int
22 items: Annotated[list[LineItem], Field(min_length=1)]
23 tax: Annotated[Decimal, Field(ge=0, decimal_places=2)] = Decimal("0.00")
24 total: Annotated[Decimal, Field(gt=0, decimal_places=2)]
25 
26 @model_validator(mode="after")
27 def check_total_matches_items(self) -> "OrderCreate":
28 expected = sum((item.subtotal for item in self.items), Decimal("0.00")) + self.tax
29 if expected != self.total:
30 raise ValueError(
31 f"total {self.total} does not match computed amount {expected} "
32 f"(items + tax)"
33 )
34 return self
35 
36 
37@router.post("", status_code=status.HTTP_201_CREATED)
38async def create_order(payload: OrderCreate) -> dict:
39 order = await OrderService.place(payload)
40 return {"id": order.id, "total": order.total}
01 / 01
STEP 01

Walkthrough

Space play step click any line
Three takeaways
  1. 1Field constraints catch shape errors while a model validator catches relationships between fields.
  2. 2Using Decimal with fixed decimal_places keeps monetary math exact instead of drifting with floats.
  3. 3Validating derived totals at the schema boundary means the route handler only ever sees consistent data.

Related explainers

Share this explainer

Here's the card — post it anywhere.

Cross-field order validation in FastAPI — share card
Made with highlit — turn any snippet into a walkthrough like this in about a minute.
Explain your code