Real data isn’t flat. A user has an address. An order has line items. A response wraps a list with pagination metadata. Pydantic lets us nest models inside models — and validates the whole tree in one shot.
In simple language — once we have a model, we can use it as a field type in another model. Pydantic handles parsing recursively, and FastAPI auto-generates nested OpenAPI schemas.
The basic shape
from pydantic import BaseModel
class Address(BaseModel):
street: str
city: str
zip_code: str
country: str = "IN"
class User(BaseModel):
id: int
name: str
address: Address # nested model as a field type
Send this JSON to a POST /users:
{
"id": 1,
"name": "Manish",
"address": {
"street": "MG Road",
"city": "Bangalore",
"zip_code": "560001"
}
}
Pydantic builds the Address instance, attaches it to user.address, and we access it as user.address.city. If the address is missing a field or has a wrong type — 422 before our handler.
Lists of models
For an order with line items, the field type is list[LineItem].
class LineItem(BaseModel):
product_id: int
quantity: int
unit_price: float
class Order(BaseModel):
id: int
customer_email: str
items: list[LineItem]
total: float
@app.post("/orders")
def create_order(order: Order):
# order.items is a list of LineItem instances
computed = sum(i.quantity * i.unit_price for i in order.items)
return {"items": len(order.items), "computed_total": computed}
Every dict inside the JSON array is validated as a LineItem. If one of them has quantity: "two", we get a 422 pointing at body.items.3.quantity.
Optional and deeply nested
We can mix Optional, list, dict freely:
class Coordinates(BaseModel):
lat: float
lon: float
class Address(BaseModel):
street: str
city: str
coords: Coordinates | None = None
class Company(BaseModel):
name: str
headquarters: Address
offices: list[Address] = []
metadata: dict[str, str] = {}
FastAPI walks the whole tree to generate the OpenAPI schema. Swagger UI shows it as a collapsible nested form.
Recursive models — self-referencing
Comment threads, file trees, org charts — anywhere a model contains itself. In v2 we use a string forward reference:
class Comment(BaseModel):
id: int
text: str
replies: list["Comment"] = []
# Optional in v2 — Pydantic resolves it automatically in most cases.
# If you hit a NameError, call:
Comment.model_rebuild()
Send a tree of arbitrary depth and Pydantic validates every level:
{
"id": 1,
"text": "Top level",
"replies": [
{"id": 2, "text": "A reply", "replies": [
{"id": 3, "text": "Nested reply", "replies": []}
]}
]
}
Serialization handles nesting too
model_dump() recursively converts the whole tree to plain dicts:
order.model_dump()
# {
# "id": 1,
# "customer_email": "...",
# "items": [{"product_id": 7, "quantity": 2, "unit_price": 19.99}, ...]
# }
Why this is a big deal for FastAPI
Each nested model becomes a $ref in the OpenAPI spec. That means:
- Swagger UI shows expandable nested forms.
- Auto-generated client SDKs (TypeScript, Python) get typed nested objects.
- Errors are pinpointed by JSON path —
body.address.zip_codenot just “bad input”.
The takeaway — model composition is just type-hinting models inside models. Pydantic handles the recursion for parsing AND serialization, and FastAPI propagates the full shape to the API docs.