Most API endpoints return more than a single flat object — a post comes with its author’s name, a comment includes the commenter’s profile, a product list shows category names. Pydantic makes nested responses elegant: declare the shape of each nested object as its own model and reference it as a field type. FastAPI serialises the whole nested structure to JSON automatically. The key challenge is building these nested Pydantic models from SQLAlchemy ORM objects that have relationships — which requires the from_attributes=True config and either eager loading or careful query construction.
Embedded Related Resources
from pydantic import BaseModel
from datetime import datetime
# ── Nested schemas ─────────────────────────────────────────────────────────────
class AuthorResponse(BaseModel):
model_config = {"from_attributes": True}
id: int
name: str
email: str
class TagResponse(BaseModel):
model_config = {"from_attributes": True}
id: int
name: str
slug: str
class CommentResponse(BaseModel):
model_config = {"from_attributes": True}
id: int
body: str
created_at: datetime
author: AuthorResponse # nested author
# ── Post response with embedded related data ──────────────────────────────────
class PostResponse(BaseModel):
model_config = {"from_attributes": True}
id: int
title: str
body: str
slug: str
status: str
view_count: int
created_at: datetime
author: AuthorResponse # nested author object
tags: list[TagResponse] = [] # list of tag objects
class PostDetailResponse(PostResponse):
"""Extended response for single post view — includes comments."""
comments: list[CommentResponse] = []
author field type (AuthorResponse), checks if the incoming data has an author attribute or key, and validates that against the AuthorResponse schema. With from_attributes=True, Pydantic reads attributes from objects (ORM instances) as well as dict keys. When building PostResponse from a SQLAlchemy Post object, post.author must be an already-loaded User ORM object — not an unloaded lazy relationship.PostListItem might include only author_name: str (flattened from the author join) and tags: list[str] (just tag names), while PostDetailResponse includes the full AuthorResponse and CommentResponse list. This reduces data transfer for list endpoints.post.author in the Pydantic serialisation step triggers 20 separate SELECT queries for authors. Always use joinedload() or selectinload() to eagerly load all relationships you will include in the response schema, as covered in Chapter 16.Building Nested Models from ORM Objects
from fastapi import FastAPI, Depends
from sqlalchemy.orm import Session, joinedload, selectinload
app = FastAPI()
@app.get("/posts/{post_id}", response_model=PostDetailResponse)
def get_post(post_id: int, db: Session = Depends(get_db)):
post = (
db.query(Post)
.options(
joinedload(Post.author), # load author in same query
selectinload(Post.tags), # load tags in second query
selectinload(Post.comments).joinedload(Comment.author), # comments + their authors
)
.filter(Post.id == post_id)
.first()
)
if not post:
raise HTTPException(404, "Post not found")
return post # PostDetailResponse.model_validate(post) happens via response_model
@app.get("/posts", response_model=list[PostResponse])
def list_posts(db: Session = Depends(get_db), limit: int = 10, page: int = 1):
return (
db.query(Post)
.options(
joinedload(Post.author),
selectinload(Post.tags),
)
.filter(Post.status == "published")
.offset((page - 1) * limit)
.limit(limit)
.all()
)
Flat vs Nested Response Design
from pydantic import BaseModel
from datetime import datetime
# ── Option A: Nested (full author object) ─────────────────────────────────────
class PostWithAuthor(BaseModel):
model_config = {"from_attributes": True}
id: int
title: str
author: AuthorResponse # {"id": 1, "name": "Alice", "email": "..."}
# JSON: {"id": 1, "title": "Hello", "author": {"id": 1, "name": "Alice"}}
# Pro: complete author data, no additional request needed
# Con: larger payload, need eager load
# ── Option B: ID reference only ───────────────────────────────────────────────
class PostWithAuthorId(BaseModel):
model_config = {"from_attributes": True}
id: int
title: str
author_id: int # just the ID
# JSON: {"id": 1, "title": "Hello", "author_id": 1}
# Pro: smaller payload, simpler query
# Con: client needs a second request for author details
# ── Option C: Flattened ───────────────────────────────────────────────────────
class PostFlat(BaseModel):
model_config = {"from_attributes": True}
id: int
title: str
author_id: int
author_name: str # just the name, not full object — from JOIN
# JSON: {"id": 1, "title": "Hello", "author_id": 1, "author_name": "Alice"}
# Pro: one query, compact payload, name available without extra request
# Con: if author name changes, this is stale
Common Mistakes
Mistake 1 — Lazy loading nested relationships during serialisation (N+1)
❌ Wrong — accessing post.author triggers N extra queries:
posts = db.query(Post).limit(20).all()
return posts # post.author loaded lazily for each → 20 extra SELECT queries!
✅ Correct — eager load with joinedload:
posts = db.query(Post).options(joinedload(Post.author)).limit(20).all() # ✓ 1 query
Mistake 2 — Missing from_attributes on nested model
❌ Wrong — nested AuthorResponse cannot read ORM attributes:
class AuthorResponse(BaseModel): # no from_attributes!
id: int; name: str
# PostResponse.model_validate(orm_post) → validation error on author field
✅ Correct — every model in the nesting chain needs from_attributes:
class AuthorResponse(BaseModel):
model_config = {"from_attributes": True} # ✓ on every nested model
id: int; name: str
Mistake 3 — Over-nesting (circular references)
❌ Wrong — Post embeds Comments, Comments embed Post → infinite recursion:
class PostResponse(BaseModel):
comments: list[CommentResponse]
class CommentResponse(BaseModel):
post: PostResponse # circular!
✅ Correct — break the cycle: CommentResponse only includes post_id, not the full PostResponse.
Quick Reference
| Pattern | Code |
|---|---|
| Nested model | author: AuthorResponse in parent model |
| Nested list | tags: list[TagResponse] = [] |
| Enable ORM reading | model_config = {"from_attributes": True} on every model |
| Eager load author | .options(joinedload(Post.author)) |
| Eager load list | .options(selectinload(Post.tags)) |
| Flattened author | author_name: str (from JOIN column alias) |