Nested Models

intermediate fastapi pydantic

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.

Model tree
User
Address
str, str, str, ...

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_code not 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.