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.
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
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
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):
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
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:
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
| Pitfall | Cause / Symptom | Fix |
|---|---|---|
422 with long loc path | Nested validation error on a deeply nested field | Read the full loc array — each element is one level deeper |
model_rebuild() not called | NameError on self-referential model | Add Category.model_rebuild() after the class definition |
| Response includes internal fields | Using DB model directly as response | Create a separate Response model with only public fields |
| Deep nesting causes large payloads | Returning entire nested tree in list endpoints | Use a shallow Summary model for list endpoints, full model for detail |
model_dump() returns nested dicts | Expected flat output | Use model_dump(mode="json") or flatten in a @classmethod factory |
Hands-On Practice
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)
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