Nested Models — Embedding Related Resources

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.

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] = []
Note: Pydantic builds nested models recursively — it reads the 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.
Tip: For list endpoints (20+ posts), embedding full nested objects can increase response size significantly. Consider a lighter summary schema for lists and a detailed schema for single-resource views. 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.
Warning: Building nested Pydantic models from SQLAlchemy ORM objects with unloaded relationships triggers lazy loading — one SQL query per object per relationship. For a list of 20 posts, accessing 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)

🧠 Test Yourself

Your PostResponse includes author: AuthorResponse. You fetch 20 posts with db.query(Post).limit(20).all() and return them. You notice 21 database queries in the logs. What is wrong?