Skip to main content

Nested Models

Real-world API payloads are rarely flat. An order contains items; an item has a category; a user has an address. Pydantic supports arbitrary nesting — and FastAPI propagates those schemas into your OpenAPI documentation automatically.

Learning Focus

By the end of this lesson you can: compose nested Pydantic models, validate lists of embedded models, handle optional nested objects, and create response schemas that flatten or reshape nested data.

Basic Nesting

app/models/order.py
from pydantic import BaseModel, Field

class Address(BaseModel):
street: str
city: str
country: str = "US"
postal_code: str

class Customer(BaseModel):
name: str
email: str
shipping_address: Address # Nested model
billing_address: Address | None = None # Optional nested model

class OrderLine(BaseModel):
product_id: int
quantity: int = Field(..., ge=1)
unit_price: float = Field(..., gt=0)

class OrderCreate(BaseModel):
customer: Customer
lines: list[OrderLine] = Field(..., min_length=1)
notes: str | None = None

Expected JSON body:

{
"customer": {
"name": "Jane Smith",
"email": "jane@example.com",
"shipping_address": {"street": "123 Main", "city": "Portland", "postal_code": "97201"}
},
"lines": [
{"product_id": 42, "quantity": 2, "unit_price": 19.99}
]
}

Lists of Models

app/models/blog.py
from pydantic import BaseModel
from datetime import datetime

class Tag(BaseModel):
id: int
name: str

class Author(BaseModel):
id: int
display_name: str

class PostResponse(BaseModel):
id: int
title: str
author: Author
tags: list[Tag]
published_at: datetime | None

FastAPI renders the full nested schema in OpenAPI, including all nested model definitions.

Nested Model Validation

Pydantic validates nested models recursively. A validation error in a nested field shows the full path:

{
"detail": [
{
"type": "string_too_short",
"loc": ["body", "customer", "shipping_address", "city"],
"msg": "String should have at least 1 character"
}
]
}

Transforming Nested Input to Flat Output

Often the database stores data flat, but the API response should be nested (or vice versa):

app/models/user.py
from pydantic import BaseModel, model_validator

class UserFlat(BaseModel):
"""Internal / DB representation"""
id: int
username: str
email: str
city: str
country: str

class UserResponse(BaseModel):
"""API response with nested location"""
id: int
username: str
email: str
location: dict # {"city": ..., "country": ...}

@classmethod
def from_flat(cls, user: UserFlat) -> "UserResponse":
return cls(
id=user.id,
username=user.username,
email=user.email,
location={"city": user.city, "country": user.country},
)

model_validate and model_dump with Nesting

app/models/usage.py
from app.models.order import OrderCreate

# Parse from dict (e.g., incoming JSON)
data = {
"customer": {
"name": "Alice",
"email": "alice@example.com",
"shipping_address": {"street": "1 Ave", "city": "NYC", "postal_code": "10001"},
},
"lines": [{"product_id": 1, "quantity": 3, "unit_price": 9.99}],
}

order = OrderCreate.model_validate(data)

# Serialize back to dict (nested)
order_dict = order.model_dump()

# Serialize to JSON string
order_json = order.model_dump_json()

# Exclude nested fields
partial = order.model_dump(exclude={"customer": {"email"}})

Self-Referential (Recursive) Models

For tree-like structures such as categories with sub-categories:

app/models/category.py
from __future__ import annotations
from pydantic import BaseModel

class Category(BaseModel):
id: int
name: str
children: list[Category] = []

Category.model_rebuild() # Required for self-referential models in Pydantic v2
{
"id": 1,
"name": "Electronics",
"children": [
{"id": 2, "name": "Phones", "children": []},
{"id": 3, "name": "Laptops", "children": []}
]
}

Common Pitfalls

PitfallCause / SymptomFix
422 with long loc pathNested validation error on a deeply nested fieldRead the full loc array — each element is one level deeper
model_rebuild() not calledNameError on self-referential modelAdd Category.model_rebuild() after the class definition
Response includes internal fieldsUsing DB model directly as responseCreate a separate Response model with only public fields
Deep nesting causes large payloadsReturning entire nested tree in list endpointsUse a shallow Summary model for list endpoints, full model for detail
model_dump() returns nested dictsExpected flat outputUse model_dump(mode="json") or flatten in a @classmethod factory

Hands-On Practice

app/models/invoice.py
from __future__ import annotations
from pydantic import BaseModel, Field, computed_field
from datetime import date

class LineItem(BaseModel):
description: str = Field(..., min_length=1)
quantity: int = Field(..., ge=1)
unit_price: float = Field(..., gt=0)

@computed_field
@property
def total(self) -> float:
return round(self.quantity * self.unit_price, 2)

class BillingAddress(BaseModel):
company: str | None = None
street: str
city: str
postal_code: str
country: str = "US"

class Invoice(BaseModel):
invoice_number: str = Field(..., pattern=r"^INV-\d{5}$")
issue_date: date
due_date: date
billing_to: BillingAddress
items: list[LineItem] = Field(..., min_length=1)
notes: str | None = None

@computed_field
@property
def subtotal(self) -> float:
return round(sum(i.total for i in self.items), 2)

@computed_field
@property
def tax(self) -> float:
return round(self.subtotal * 0.1, 2)

@computed_field
@property
def grand_total(self) -> float:
return round(self.subtotal + self.tax, 2)
test_invoice.py
from app.models.invoice import Invoice, LineItem, BillingAddress
from datetime import date

inv = Invoice(
invoice_number="INV-00001",
issue_date=date(2024, 1, 15),
due_date=date(2024, 2, 15),
billing_to=BillingAddress(street="1 Commerce Rd", city="Boston", postal_code="02101"),
items=[
LineItem(description="FastAPI Consulting", quantity=10, unit_price=150.0),
LineItem(description="Deployment Setup", quantity=1, unit_price=500.0),
],
)
print(f"Subtotal: ${inv.subtotal}") # $2000.00
print(f"Tax: ${inv.tax}") # $200.00
print(f"Total: ${inv.grand_total}")# $2200.00

What's Next