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
‹ swipe to step through ›
Walkthrough
Space play
←→ step
click any line
Three takeaways
- 1Field constraints catch shape errors while a model validator catches relationships between fields.
- 2Using Decimal with fixed decimal_places keeps monetary math exact instead of drifting with floats.
- 3Validating derived totals at the schema boundary means the route handler only ever sees consistent data.
Related explainers
python
from fastapi import FastAPI, WebSocket, WebSocketDisconnect app = FastAPI()
Building a WebSocket chat with FastAPI
websockets
broadcast
connection-management
Intermediate
9 steps
php
<?php namespace App\Services\Checkout;
Validating coupons with Laravel's Pipeline
pipeline
chain of responsibility
transactions
Intermediate
7 steps
python
import time import uuid from django.utils.deprecation import MiddlewareMixin
Attaching per-request context in Django
middleware
request lifecycle
multi-tenancy
Intermediate
7 steps
php
<?php namespace App\Services;
How a password strength validator works in PHP
validation
regular-expressions
data-driven
Intermediate
8 steps
python
import random from typing import Iterator, List
How reservoir sampling picks k items
reservoir-sampling
streaming
randomness
Intermediate
5 steps
rust
use chrono::{Duration, NaiveDate}; #[derive(Debug)] pub struct DateRange {
Parsing and iterating date ranges in Rust
error-handling
iterators
parsing
Intermediate
7 steps
Share this explainer
Here's the card — post it anywhere.
Made with highlit — turn any snippet into a walkthrough like this in about a minute.
Explain your code
Embed this explainer
Drop the interactive walkthrough into a blog or docs. Views never cost a credit.
<iframe src="https://highlit.co/explainers/cross-field-order-validation-in-fastapi-explained-python-70f5/embed?autoplay=1" width="100%" height="520" loading="lazy" style="border:0"></iframe>
Autoplay is on by default — add ?autoplay=0 to start paused.