Nested Models in Pydantic: Composing Complex Data
Real-world data is rarely flat. An API response contains a user with an address with a country code. A database document has lists of nested objects. A form has conditional sections. Pydantic excels at composing models into trees—each level validated independently, errors reported with precise paths, and serialization cascading through the entire structure. This article shows how to build and validate complex hierarchies efficiently.
Simple Nesting: Model Within Model
The simplest nesting is a model as a field type:
from pydantic import BaseModel, EmailStr
class Address(BaseModel):
street: str
city: str
zip_code: str
class User(BaseModel):
name: str
email: EmailStr
address: Address # Nested model
# Create from nested dict
user_data = {
"name": "Alice",
"email": "[email protected]",
"address": {
"street": "123 Main St",
"city": "Portland",
"zip_code": "97201"
}
}
user = User(**user_data)
print(user.address.street) # "123 Main St"
# Invalid nested data
try:
bad_data = {
"name": "Bob",
"email": "[email protected]",
"address": {
"street": "456 Oak Ave",
"city": "New York"
# Missing zip_code
}
}
bad = User(**bad_data)
except Exception as e:
print("zip_code required in address")
When Pydantic validates the outer model, it recursively validates the nested model. If the nested data is invalid, Pydantic returns an error with the field path: address.zip_code is required.
Lists of Models
Frequently, you need a list of nested models:
from pydantic import BaseModel, Field
class Product(BaseModel):
id: int
name: str
price: float = Field(gt=0)
class Order(BaseModel):
order_id: int
items: list[Product] # List of models
total: float
# Valid
order_data = {
"order_id": 101,
"items": [
{"id": 1, "name": "Laptop", "price": 999.99},
{"id": 2, "name": "Mouse", "price": 29.99}
],
"total": 1029.98
}
order = Order(**order_data)
print(len(order.items)) # 2
print(order.items[0].name) # "Laptop"
# Invalid: one item has negative price
try:
bad = Order(
order_id=102,
items=[
{"id": 1, "name": "Monitor", "price": -500} # Invalid price
],
total=0
)
except Exception as e:
print("price must be positive")
Pydantic validates every item in the list. If any item fails validation, the entire order is rejected with an error pointing to the specific item index.
Dictionaries of Models
Maps of models are also valid:
from pydantic import BaseModel
class TeamMember(BaseModel):
name: str
role: str
class Team(BaseModel):
name: str
members: dict[str, TeamMember] # Username -> member info
# Valid
team_data = {
"name": "Platform Team",
"members": {
"alice": {"name": "Alice", "role": "Lead"},
"bob": {"name": "Bob", "role": "Engineer"},
"carol": {"name": "Carol", "role": "Designer"}
}
}
team = Team(**team_data)
print(team.members["alice"].role) # "Lead"
# Invalid: member data is incomplete
try:
bad = Team(
name="Team",
members={
"dave": {"name": "Dave"} # Missing role
}
)
except Exception as e:
print("Error in members['dave']: role required")
Dictionaries are useful for keyed collections where lookups by key are common.
Optional Nested Models
A nested model can be optional:
from pydantic import BaseModel
from typing import Optional
class BillingAddress(BaseModel):
street: str
city: str
zip_code: str
class Order(BaseModel):
order_id: int
shipping_address: BillingAddress
billing_address: Optional[BillingAddress] = None # Optional
# Valid: no billing address (defaults to None)
order = Order(
order_id=1,
shipping_address={
"street": "123 Main",
"city": "NYC",
"zip_code": "10001"
}
)
print(order.billing_address) # None
# Valid: explicit billing address
order2 = Order(
order_id=2,
shipping_address={
"street": "123 Main",
"city": "NYC",
"zip_code": "10001"
},
billing_address={
"street": "456 Oak",
"city": "LA",
"zip_code": "90001"
}
)
print(order2.billing_address.city) # "LA"
Use Optional[ModelType] to allow None values or omit the field entirely.
Recursive and Self-Referencing Models
Models can reference themselves—useful for trees and linked structures:
from pydantic import BaseModel
from typing import Optional
class TreeNode(BaseModel):
value: int
left: Optional["TreeNode"] = None # Reference to self
right: Optional["TreeNode"] = None
# Build a small tree
tree = TreeNode(
value=1,
left=TreeNode(value=2),
right=TreeNode(value=3, left=TreeNode(value=4))
)
# In-order traversal (mock)
def traverse(node):
if node is None:
return
print(node.value)
traverse(node.left)
traverse(node.right)
# This works because Pydantic handles forward references
Forward references (quoted strings like "TreeNode") tell Pydantic to resolve the reference after the class is defined. Call TreeNode.model_rebuild() if forward refs don't resolve automatically.
Practical Example: Blog Post with Comments
Here's a realistic nested structure:
from pydantic import BaseModel, EmailStr, Field
from datetime import datetime
from typing import Optional
class Author(BaseModel):
name: str
email: EmailStr
class Comment(BaseModel):
id: int
author: Author
text: str = Field(min_length=1, max_length=500)
created_at: datetime
class Post(BaseModel):
id: int
title: str = Field(min_length=1, max_length=200)
body: str = Field(min_length=10)
author: Author
created_at: datetime
comments: list[Comment] = Field(default_factory=list)
featured: bool = False
# Valid blog post with nested author and comments
post_data = {
"id": 1,
"title": "Python Tips",
"body": "Here are five essential Python tips for beginners...",
"author": {"name": "Alice", "email": "[email protected]"},
"created_at": "2026-06-01T10:00:00Z",
"comments": [
{
"id": 1,
"author": {"name": "Bob", "email": "[email protected]"},
"text": "Great article!",
"created_at": "2026-06-01T11:00:00Z"
},
{
"id": 2,
"author": {"name": "Carol", "email": "[email protected]"},
"text": "Could you elaborate on tip #3?",
"created_at": "2026-06-01T12:00:00Z"
}
],
"featured": True
}
post = Post(**post_data)
print(post.title) # "Python Tips"
print(post.author.email) # "[email protected]"
print(post.comments[0].author.name) # "Bob"
This structure validates recursively: author is validated as an Author model, each comment is validated as a Comment model (which includes its own Author validation), and the post assembles them all.
Discriminated Unions of Nested Models
When you have multiple nested types, use discriminated unions:
from pydantic import BaseModel, Field
from typing import Union, Literal
class EmailNotification(BaseModel):
type: Literal["email"]
email: str
subject: str
class SMSNotification(BaseModel):
type: Literal["sms"]
phone: str
message: str
class User(BaseModel):
name: str
notification: Union[EmailNotification, SMSNotification] = Field(
discriminator="type"
)
# Valid: email notification
user1 = User(
name="Alice",
notification={
"type": "email",
"email": "[email protected]",
"subject": "Welcome!"
}
)
# Valid: SMS notification
user2 = User(
name="Bob",
notification={
"type": "sms",
"phone": "+1234567890",
"message": "Hello Bob!"
}
)
print(isinstance(user1.notification, EmailNotification)) # True
print(isinstance(user2.notification, SMSNotification)) # True
The discriminator="type" field tells Pydantic which union member to instantiate based on the value of the type field. This is much faster than trying each variant.
Validation Path Errors
When validation fails in a deeply nested structure, Pydantic reports the exact path:
from pydantic import ValidationError
# Assume the same Post and Author models from earlier
try:
bad_post = Post(
id=1,
title="Tips",
body="Valid long body...",
author={"name": "Alice"}, # Missing email
created_at="2026-06-01T10:00:00Z",
comments=[
{
"id": 1,
"author": {"name": "Bob", "email": "[email protected]"},
"text": "Great!",
"created_at": "2026-06-01T11:00:00Z"
},
{
"id": 2,
"author": {"name": "Carol"}, # Missing email
"text": "More please!",
"created_at": "2026-06-01T12:00:00Z"
}
]
)
except ValidationError as e:
errors = e.errors()
for err in errors:
print(err["loc"]) # Shows path like ("author", "email")
# or ("comments", 1, "author", "email")
The loc (location) field in errors shows the path to the invalid field, making it easy to pinpoint issues in complex structures.
Key Takeaways
- Declare a model as a field type to nest it:
address: Address. - Validate lists of models with
list[ModelType]; every item is validated. - Dictionaries of models:
dict[str, ModelType]for keyed collections. - Use
Optional[ModelType]for optional nested models. - Forward references (
"ModelType") support recursive/self-referencing models. - Discriminated unions (
Union[Type1, Type2]withdiscriminator) handle polymorphic nesting. - Error paths show exactly where validation failed in nested structures.
Frequently Asked Questions
How deep can nesting go?
Theoretically unlimited, but practically limited by Python's recursion limit (default 1000). For deeply nested structures (> 100 levels), consider flattening or streaming validation.
Do nested models share validation rules?
No. Each model instance is validated independently. If Author is used in multiple places, it's validated the same way each time.
Can I inherit from a nested model?
Yes. Child classes inherit parent fields and can add their own, making it easy to extend schemas.
What's the performance cost of nesting?
Negligible. Pydantic validates each level in microseconds. A post with 100 nested comments (10 fields each) validates in 2-5ms total.
How do I exclude fields when serializing nested models?
Use model_dump(exclude={"field_name"}) or model_dump(exclude={"comments": {"__all__": {"author"}}}) to exclude nested fields.