Module 3: Advanced Response Models

Advanced response_model

Capsule overview

In FastAPI Fundamentals you used response_model to define the shape of an endpoint's response. You declared a Pydantic model and FastAPI took care of serializing and documenting it. But that basic version has limits: it always returns every field in the model, you can't exclude sensitive fields dynamically, and on PATCH endpoints it returns default values the client never sent.

In this capsule you're going to master advanced response_model control. You'll learn response_model_include and response_model_exclude to control which fields go out, response_model_exclude_unset for a correct PATCH, multiple schemas per resource (Public vs Admin vs Summary), and Union types for endpoints that can return different shapes of data. By the end, every endpoint in your API will return exactly what each consumer needs — no more, no less.


The base model: Task with internal fields

We're going to work with a Task model that has both public and internal fields:

from pydantic import BaseModel, Field
from datetime import datetime
from typing import Optional


class TaskInDB(BaseModel):
    id: int
    title: str = Field(min_length=1, max_length=200)
    description: Optional[str] = None
    status: str = Field(default="pending", pattern="^(pending|in_progress|completed)$")
    priority: int = Field(default=1, ge=1, le=5)
    due_date: Optional[str] = None
    created_at: datetime = Field(default_factory=datetime.now)
    updated_at: Optional[datetime] = None
    created_by: str = "system"
    internal_notes: Optional[str] = None
    is_archived: bool = False

This model represents what you store in the "database". It has 11 fields. But not all of them should be visible to every consumer. A public user doesn't need internal_notes, created_by, or is_archived. A summary for listings doesn't need the full description or internal_notes.


response_model_exclude: hiding specific fields

The most direct way to hide fields is response_model_exclude:

from fastapi import FastAPI
from pydantic import BaseModel, Field
from datetime import datetime
from typing import Optional

app = FastAPI()


class TaskInDB(BaseModel):
    id: int
    title: str = Field(min_length=1, max_length=200)
    description: Optional[str] = None
    status: str = Field(default="pending", pattern="^(pending|in_progress|completed)$")
    priority: int = Field(default=1, ge=1, le=5)
    due_date: Optional[str] = None
    created_at: datetime = Field(default_factory=datetime.now)
    updated_at: Optional[datetime] = None
    created_by: str = "system"
    internal_notes: Optional[str] = None
    is_archived: bool = False


tasks_db: list[TaskInDB] = [
    TaskInDB(id=1, title="Set up CI/CD", status="in_progress", priority=4, created_by="admin", internal_notes="Review with DevOps"),
    TaskInDB(id=2, title="Write tests", status="pending", priority=3, created_by="dev-lead"),
    TaskInDB(id=3, title="Document the API", description="Add docstrings and examples", status="completed", priority=2),
]


@app.get(
    "/tasks",
    response_model=list[TaskInDB],
    response_model_exclude={"internal_notes", "created_by", "is_archived"},
)
def list_tasks():
    return tasks_db
curl http://127.0.0.1:8000/tasks
[
    {
        "id": 1,
        "title": "Set up CI/CD",
        "description": null,
        "status": "in_progress",
        "priority": 4,
        "due_date": null,
        "created_at": "2026-03-13T10:30:00",
        "updated_at": null
    }
]

internal_notes, created_by and is_archived disappear from the response. FastAPI filters them out after your function returns, so internally you keep working with the complete object.


response_model_include: only specific fields

The inverse of exclude — you declare which fields DO get included:

@app.get(
    "/tasks/summary",
    response_model=list[TaskInDB],
    response_model_include={"id", "title", "status", "priority"},
)
def list_tasks_summary():
    return tasks_db
curl http://127.0.0.1:8000/tasks/summary
[
    {"id": 1, "title": "Set up CI/CD", "status": "in_progress", "priority": 4},
    {"id": 2, "title": "Write tests", "status": "pending", "priority": 3},
    {"id": 3, "title": "Document the API", "status": "completed", "priority": 2}
]

Just 4 fields per task. Ideal for listings where you don't need the full detail.

When to use include vs exclude?

SituationUse
You want to hide 2-3 sensitive fieldsresponse_model_exclude
You want a small subset (3-5 fields out of 15)response_model_include
The model has few fields and you want most of themresponse_model_exclude
Security: an explicit allowlist of fieldsresponse_model_include (safer)

In security contexts, response_model_include is preferable: if you add a sensitive field to the model, with exclude you have to remember to exclude it in every endpoint. With include, new fields don't go out unless you add them explicitly.


response_model_exclude_unset: a correct PATCH

This is one of the most useful and least understood parameters. Picture a PATCH endpoint:

class TaskUpdate(BaseModel):
    title: Optional[str] = None
    description: Optional[str] = None
    status: Optional[str] = None
    priority: Optional[int] = None


@app.patch("/tasks/{task_id}", response_model=TaskInDB)
def update_task(task_id: int, updates: TaskUpdate):
    task = next((t for t in tasks_db if t.id == task_id), None)
    if task is None:
        raise HTTPException(status_code=404, detail="Task not found")

    update_data = updates.model_dump(exclude_unset=True)
    for field, value in update_data.items():
        setattr(task, field, value)
    task.updated_at = datetime.now()

    return task

The client sends only the fields it wants to change:

curl -X PATCH http://127.0.0.1:8000/tasks/1 \
  -H "Content-Type: application/json" \
  -d '{"status": "completed"}'

Without response_model_exclude_unset, the response includes EVERY field — even the ones sitting at their default value that the client never touched. Sometimes that's fine, but other times the client wants to know exactly what changed.

With response_model_exclude_unset=True:

@app.patch(
    "/tasks/{task_id}",
    response_model=TaskInDB,
    response_model_exclude_unset=True,
)
def update_task(task_id: int, updates: TaskUpdate):
    task = next((t for t in tasks_db if t.id == task_id), None)
    if task is None:
        raise HTTPException(status_code=404, detail="Task not found")

    update_data = updates.model_dump(exclude_unset=True)
    for field, value in update_data.items():
        setattr(task, field, value)
    task.updated_at = datetime.now()

    return task

Now the response only includes fields that were explicitly set — not the ones still holding their default. This is especially useful so the client knows exactly what data the updated resource holds, without confusing defaults with real data.


Multiple schemas per resource

The cleanest solution for different views is to create separate schemas. Instead of using include/exclude in every endpoint, you define explicit Pydantic models:

from pydantic import BaseModel, Field
from datetime import datetime
from typing import Optional


class TaskBase(BaseModel):
    title: str = Field(min_length=1, max_length=200)
    description: Optional[str] = None
    status: str = Field(default="pending", pattern="^(pending|in_progress|completed)$")
    priority: int = Field(default=1, ge=1, le=5)
    due_date: Optional[str] = None


class TaskCreate(TaskBase):
    pass


class TaskUpdate(BaseModel):
    title: Optional[str] = Field(default=None, min_length=1, max_length=200)
    description: Optional[str] = None
    status: Optional[str] = Field(default=None, pattern="^(pending|in_progress|completed)$")
    priority: Optional[int] = Field(default=None, ge=1, le=5)
    due_date: Optional[str] = None


class TaskPublic(TaskBase):
    """Public view — no internal fields."""
    id: int
    created_at: datetime


class TaskAdmin(TaskBase):
    """Admin view — every field."""
    id: int
    created_at: datetime
    updated_at: Optional[datetime] = None
    created_by: str
    internal_notes: Optional[str] = None
    is_archived: bool


class TaskSummary(BaseModel):
    """Summary view — for compact listings."""
    id: int
    title: str
    status: str
    priority: int


class TaskInDB(TaskAdmin):
    """The complete model in the database."""
    pass

The hierarchy is clear:

TaskBase (common editable fields)
├── TaskCreate (for POST — inherits from base)
├── TaskPublic (for public responses — base + id + created_at)
└── TaskAdmin (for admin responses — base + every internal field)
    └── TaskInDB (the DB model — identical to Admin for now)

TaskUpdate (for PATCH — everything Optional)
TaskSummary (for listings — a minimal subset)

Using the schemas in endpoints

from fastapi import FastAPI, HTTPException

app = FastAPI()

tasks_db: list[TaskInDB] = [
    TaskInDB(
        id=1, title="Set up CI/CD", status="in_progress",
        priority=4, created_by="admin", internal_notes="Review with DevOps",
        created_at=datetime.now(), is_archived=False,
    ),
    TaskInDB(
        id=2, title="Write tests", status="pending",
        priority=3, created_by="dev-lead",
        created_at=datetime.now(), is_archived=False,
    ),
]


@app.get("/tasks", response_model=list[TaskPublic])
def list_tasks():
    """Public view: no internal_notes, created_by, is_archived."""
    return tasks_db


@app.get("/tasks/admin", response_model=list[TaskAdmin])
def list_tasks_admin():
    """Admin view: every field."""
    return tasks_db


@app.get("/tasks/summary", response_model=list[TaskSummary])
def list_tasks_summary():
    """Summary view: only id, title, status, priority."""
    return tasks_db


@app.get("/tasks/{task_id}", response_model=TaskPublic)
def get_task(task_id: int):
    task = next((t for t in tasks_db if t.id == task_id), None)
    if task is None:
        raise HTTPException(status_code=404, detail="Task not found")
    return task


@app.post("/tasks", response_model=TaskPublic, status_code=201)
def create_task(task: TaskCreate):
    new_id = max((t.id for t in tasks_db), default=0) + 1
    task_in_db = TaskInDB(
        id=new_id,
        **task.model_dump(),
        created_at=datetime.now(),
        created_by="system",
        is_archived=False,
    )
    tasks_db.append(task_in_db)
    return task_in_db

Every endpoint returns a TaskInDB internally, but FastAPI serializes according to the declared response_model. The same task_in_db object can go through TaskPublic (which excludes internal fields) or TaskAdmin (which includes everything). You don't need to transform the data by hand.


Union types in response_model

Sometimes an endpoint can return different shapes depending on the situation. For example, an endpoint that returns a task or an error message:

from typing import Union


class TaskPublic(BaseModel):
    id: int
    title: str
    status: str
    priority: int
    created_at: datetime


class TaskDeleted(BaseModel):
    id: int
    message: str
    deleted_at: datetime


@app.delete("/tasks/{task_id}", response_model=Union[TaskDeleted, TaskPublic])
def delete_task(task_id: int):
    task = next((t for t in tasks_db if t.id == task_id), None)
    if task is None:
        raise HTTPException(status_code=404, detail="Task not found")

    tasks_db.remove(task)
    return TaskDeleted(
        id=task_id,
        message=f"Task '{task.title}' deleted",
        deleted_at=datetime.now(),
    )

With Union[TaskDeleted, TaskPublic], the documentation in /docs shows both possible schemas. The consumer knows it can receive either shape.

Union with a discriminator

So OpenAPI can tell the types apart more reliably, you can use a discriminator field:

from typing import Literal, Union
from pydantic import BaseModel


class SuccessResult(BaseModel):
    type: Literal["success"] = "success"
    task: TaskPublic
    message: str


class ErrorResult(BaseModel):
    type: Literal["error"] = "error"
    detail: str
    error_code: str


TaskResponse = Union[SuccessResult, ErrorResult]


@app.get("/tasks/{task_id}", response_model=TaskResponse)
def get_task(task_id: int):
    task = next((t for t in tasks_db if t.id == task_id), None)
    if task is None:
        return ErrorResult(detail="Task not found", error_code="TASK_NOT_FOUND")
    return SuccessResult(task=task, message="Task retrieved")

The type field with Literal acts as a discriminator — the client can check response.type to know which shape it got.


Combining response_model with Depends

Response schemas combine naturally with dependency injection:

from fastapi import Depends, Query


def get_response_schema(admin: bool = Query(default=False)):
    """Dependency that decides whether to use the admin view."""
    return admin


@app.get("/tasks/{task_id}")
def get_task(task_id: int, is_admin: bool = Depends(get_response_schema)):
    task = next((t for t in tasks_db if t.id == task_id), None)
    if task is None:
        raise HTTPException(status_code=404, detail="Task not found")

    if is_admin:
        return task.model_dump()
    return task.model_dump(include={"id", "title", "status", "priority", "created_at"})

Here we use model_dump(include=...) directly on the Pydantic model, which is the programmatic way of doing what response_model_include does at the decorator level. This is useful when the decision about which fields to include depends on runtime logic (like user permissions).


Pattern: response_model with model_config

Pydantic v2 lets you configure a model's behavior with model_config:

from pydantic import BaseModel, ConfigDict


class TaskResponse(BaseModel):
    model_config = ConfigDict(
        from_attributes=True,
        json_schema_extra={
            "examples": [
                {
                    "id": 1,
                    "title": "Set up CI/CD",
                    "status": "in_progress",
                    "priority": 4,
                }
            ]
        },
    )

    id: int
    title: str
    status: str
    priority: int

from_attributes=True lets you build the model from objects with attributes (like ORM models), not just from dicts. json_schema_extra adds examples to the OpenAPI schema — they show up in /docs.


Connection with the project

The multiple schemas you created here plug straight into the project capsule (05). Your Task Manager API will use TaskPublic for normal listings, TaskAdmin for administrative views, TaskSummary for compact endpoints, and TaskUpdate with exclude_unset for a correct PATCH. The schema hierarchy is reusable and scales.


Troubleshooting

Problem 1: response_model_exclude doesn't exclude nested fields

Cause: response_model_exclude only works at the top level. If you have a user: UserModel field with subfields, you can't exclude user.password directly.

Fix: Create a separate schema for the nested object:

# ❌ Doesn't work
response_model_exclude={"user.password"}

# ✅ Create a schema without password
class UserPublic(BaseModel):
    name: str
    email: str

class TaskWithUser(BaseModel):
    id: int
    title: str
    user: UserPublic  # no longer has password

Problem 2: response_model_exclude_unset excludes fields that do have a value

Cause: exclude_unset excludes fields that weren't explicitly set when the model instance was created. If a field has a default value and wasn't passed to the constructor, it counts as "unset".

Fix: If you need a field with a default to always show up in the response, set it explicitly:

# The "status" field has default="pending"
# If you don't pass it to the constructor, exclude_unset will drop it

# ❌ status doesn't show up in the response
task = TaskInDB(id=1, title="Test")

# ✅ status shows up because it was set explicitly
task = TaskInDB(id=1, title="Test", status="pending")

Problem 3: The Union type always resolves to the first type

Cause: Pydantic v2 validates Union types in order. If the first type accepts the data, it uses that type. If both types have similar fields, there can be confusion.

Fix: Make sure the types in the Union have distinct fields, or use a discriminator field with Literal:

# ❌ Both types have "id" and "title" — Pydantic can mix them up
Union[TaskPublic, TaskSummary]

# ✅ A discriminator field resolves the ambiguity
class TaskPublic(BaseModel):
    type: Literal["full"] = "full"
    id: int
    title: str
    status: str

class TaskSummary(BaseModel):
    type: Literal["summary"] = "summary"
    id: int
    title: str

Problem 4: "Value error, TaskPublic expected dict not TaskInDB"

Cause: In Pydantic v2, passing an object of one model into another requires from_attributes=True or an explicit conversion.

Fix:

class TaskPublic(BaseModel):
    model_config = ConfigDict(from_attributes=True)
    id: int
    title: str
    status: str

# Now you can do:
task_in_db = TaskInDB(id=1, title="Test", ...)
task_public = TaskPublic.model_validate(task_in_db)

Problem 5: The schemas don't show up correctly in /docs

Cause: If you return a dict instead of a Pydantic model, FastAPI can't apply response_model correctly.

Fix: Make sure you return the model object, or a dict with the same fields:

# ✅ Return the object — FastAPI applies response_model
return task_in_db

# ✅ Return a dict with the right fields
return task_in_db.model_dump()

# ❌ Return a partial dict missing fields the response_model requires
return {"id": task_in_db.id}  # "title", "status", etc. are missing

Exercises

Exercise 1: A basic Public vs Admin schema (Easy)

Create a UserInDB model with fields: id, username, email, password_hash, is_admin, created_at, last_login. Create UserPublic (without password_hash, is_admin, last_login) and UserAdmin (every field). Implement GET /users with response_model=list[UserPublic] and GET /users/admin with response_model=list[UserAdmin].

See solution
from fastapi import FastAPI
from pydantic import BaseModel, Field
from datetime import datetime
from typing import Optional

app = FastAPI()


class UserBase(BaseModel):
    username: str = Field(min_length=3, max_length=50)
    email: str


class UserPublic(UserBase):
    id: int
    created_at: datetime


class UserAdmin(UserBase):
    id: int
    password_hash: str
    is_admin: bool
    created_at: datetime
    last_login: Optional[datetime] = None


class UserInDB(UserAdmin):
    pass


users_db: list[UserInDB] = [
    UserInDB(
        id=1, username="ana_garcia", email="ana@example.com",
        password_hash="$2b$12$abc123...", is_admin=False,
        created_at=datetime(2026, 1, 15), last_login=datetime(2026, 3, 10),
    ),
    UserInDB(
        id=2, username="carlos_admin", email="carlos@example.com",
        password_hash="$2b$12$def456...", is_admin=True,
        created_at=datetime(2025, 11, 1), last_login=datetime(2026, 3, 13),
    ),
]


@app.get("/users", response_model=list[UserPublic])
def list_users():
    return users_db


@app.get("/users/admin", response_model=list[UserAdmin])
def list_users_admin():
    return users_db
curl http://127.0.0.1:8000/users
# → [{"id": 1, "username": "ana_garcia", "email": "ana@example.com", "created_at": "2026-01-15T00:00:00"}, ...]

curl http://127.0.0.1:8000/users/admin
# → [{"id": 1, "username": "ana_garcia", "email": "ana@example.com", "password_hash": "$2b$12$abc123...", ...}, ...]

Exercise 2: response_model_exclude_unset with PATCH (Easy)

Create a Product model with id, name, price, category, in_stock (default True), discount (default 0.0). Implement PATCH /products/{product_id} using response_model_exclude_unset=True. Verify that if you only update price, the response doesn't include discount or in_stock (because they weren't set during the update).

See solution
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field
from typing import Optional

app = FastAPI()


class Product(BaseModel):
    id: int
    name: str
    price: float = Field(ge=0)
    category: str
    in_stock: bool = True
    discount: float = Field(default=0.0, ge=0, le=1)


class ProductUpdate(BaseModel):
    name: Optional[str] = None
    price: Optional[float] = Field(default=None, ge=0)
    category: Optional[str] = None
    in_stock: Optional[bool] = None
    discount: Optional[float] = Field(default=None, ge=0, le=1)


products_db: list[Product] = [
    Product(id=1, name="Laptop Pro", price=1299.99, category="Electronics"),
    Product(id=2, name="Python Book", price=35.00, category="Books", discount=0.1),
]


@app.patch(
    "/products/{product_id}",
    response_model=Product,
    response_model_exclude_unset=True,
)
def update_product(product_id: int, updates: ProductUpdate):
    product = next((p for p in products_db if p.id == product_id), None)
    if product is None:
        raise HTTPException(status_code=404, detail="Product not found")

    update_data = updates.model_dump(exclude_unset=True)
    for field, value in update_data.items():
        setattr(product, field, value)

    return product
curl -X PATCH http://127.0.0.1:8000/products/1 \
  -H "Content-Type: application/json" \
  -d '{"price": 999.99}'
# → {"id": 1, "name": "Laptop Pro", "price": 999.99, "category": "Electronics"}
# Note: in_stock and discount do NOT show up because they weren't set

Exercise 3: Three views of the same resource (Medium)

Create an Article model with: id, title, content (long text), author, tags (list[str]), published, views_count, created_at, editor_notes. Create three schemas:

  • ArticleSummary — only id, title, author, published
  • ArticlePublic — everything except editor_notes and views_count
  • ArticleAdmin — every field

Implement three endpoints: GET /articles (summary), GET /articles/{id} (public), GET /articles/{id}/admin (admin).

See solution
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field
from datetime import datetime
from typing import Optional

app = FastAPI()


class ArticleBase(BaseModel):
    title: str = Field(min_length=1, max_length=300)
    content: str
    author: str
    tags: list[str] = Field(default_factory=list)
    published: bool = False


class ArticleSummary(BaseModel):
    id: int
    title: str
    author: str
    published: bool


class ArticlePublic(ArticleBase):
    id: int
    created_at: datetime


class ArticleAdmin(ArticleBase):
    id: int
    views_count: int
    created_at: datetime
    editor_notes: Optional[str] = None


class ArticleInDB(ArticleAdmin):
    pass


articles_db: list[ArticleInDB] = [
    ArticleInDB(
        id=1, title="Introduction to FastAPI", content="FastAPI is a framework...",
        author="Ana García", tags=["python", "fastapi"], published=True,
        views_count=1523, created_at=datetime(2026, 2, 10),
        editor_notes="Review the middleware section",
    ),
    ArticleInDB(
        id=2, title="Pydantic v2 Migration", content="Migrating from Pydantic v1 to v2...",
        author="Carlos López", tags=["python", "pydantic"], published=False,
        views_count=0, created_at=datetime(2026, 3, 1),
        editor_notes="Technical review pending",
    ),
]


@app.get("/articles", response_model=list[ArticleSummary])
def list_articles():
    return articles_db


@app.get("/articles/{article_id}", response_model=ArticlePublic)
def get_article(article_id: int):
    article = next((a for a in articles_db if a.id == article_id), None)
    if article is None:
        raise HTTPException(status_code=404, detail="Article not found")
    return article


@app.get("/articles/{article_id}/admin", response_model=ArticleAdmin)
def get_article_admin(article_id: int):
    article = next((a for a in articles_db if a.id == article_id), None)
    if article is None:
        raise HTTPException(status_code=404, detail="Article not found")
    return article
curl http://127.0.0.1:8000/articles
# → [{"id": 1, "title": "Introduction to FastAPI", "author": "Ana García", "published": true}, ...]

curl http://127.0.0.1:8000/articles/1
# → {every field except views_count and editor_notes}

curl http://127.0.0.1:8000/articles/1/admin
# → {EVERY field including views_count and editor_notes}

Exercise 4: Union type with a discriminator (Medium)

Create a notifications system where GET /notifications/{id} can return EmailNotification (with recipient_email, subject) or SMSNotification (with phone_number, message). Use a type field with Literal as the discriminator. The response_model should be Union[EmailNotification, SMSNotification].

See solution
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from typing import Literal, Union
from datetime import datetime

app = FastAPI()


class EmailNotification(BaseModel):
    id: int
    type: Literal["email"] = "email"
    recipient_email: str
    subject: str
    sent_at: datetime


class SMSNotification(BaseModel):
    id: int
    type: Literal["sms"] = "sms"
    phone_number: str
    message: str
    sent_at: datetime


NotificationType = Union[EmailNotification, SMSNotification]

notifications_db: list[NotificationType] = [
    EmailNotification(
        id=1, recipient_email="ana@example.com",
        subject="Welcome", sent_at=datetime(2026, 3, 10),
    ),
    SMSNotification(
        id=2, phone_number="+52 55 1234 5678",
        message="Your code is 4829", sent_at=datetime(2026, 3, 12),
    ),
    EmailNotification(
        id=3, recipient_email="carlos@example.com",
        subject="Weekly report", sent_at=datetime(2026, 3, 13),
    ),
]


@app.get("/notifications", response_model=list[NotificationType])
def list_notifications():
    return notifications_db


@app.get("/notifications/{notification_id}", response_model=NotificationType)
def get_notification(notification_id: int):
    notification = next((n for n in notifications_db if n.id == notification_id), None)
    if notification is None:
        raise HTTPException(status_code=404, detail="Notification not found")
    return notification
curl http://127.0.0.1:8000/notifications/1
# → {"id": 1, "type": "email", "recipient_email": "ana@example.com", "subject": "Welcome", ...}

curl http://127.0.0.1:8000/notifications/2
# → {"id": 2, "type": "sms", "phone_number": "+52 55 1234 5678", "message": "Your code is 4829", ...}

Exercise 5: A dynamic schema with a query parameter (Hard)

Create a GET /orders/{order_id} endpoint that accepts a detail_level query parameter with the values "minimal", "standard", "full". Depending on the value, it returns different fields of an Order model. Use model_dump(include=...) to control the fields dynamically.

See solution
from fastapi import FastAPI, HTTPException, Query
from pydantic import BaseModel, Field
from datetime import datetime
from typing import Optional, Literal

app = FastAPI()


class Order(BaseModel):
    id: int
    customer_name: str
    customer_email: str
    items: list[str]
    total: float
    status: str
    payment_method: str
    shipping_address: str
    tracking_number: Optional[str] = None
    created_at: datetime
    internal_notes: Optional[str] = None


orders_db: list[Order] = [
    Order(
        id=1, customer_name="Ana García", customer_email="ana@example.com",
        items=["Laptop Pro", "Mouse"], total=1329.98, status="shipped",
        payment_method="credit_card", shipping_address="123 Main Street, Mexico City",
        tracking_number="MX12345678", created_at=datetime(2026, 3, 10),
        internal_notes="VIP customer",
    ),
]

DETAIL_FIELDS = {
    "minimal": {"id", "status", "total"},
    "standard": {"id", "customer_name", "items", "total", "status", "created_at"},
    "full": {"id", "customer_name", "customer_email", "items", "total", "status",
             "payment_method", "shipping_address", "tracking_number", "created_at",
             "internal_notes"},
}


@app.get("/orders/{order_id}")
def get_order(
    order_id: int,
    detail_level: Literal["minimal", "standard", "full"] = Query(default="standard"),
):
    order = next((o for o in orders_db if o.id == order_id), None)
    if order is None:
        raise HTTPException(status_code=404, detail="Order not found")

    fields = DETAIL_FIELDS[detail_level]
    return order.model_dump(include=fields)
curl "http://127.0.0.1:8000/orders/1?detail_level=minimal"
# → {"id": 1, "status": "shipped", "total": 1329.98}

curl "http://127.0.0.1:8000/orders/1?detail_level=standard"
# → {"id": 1, "customer_name": "Ana García", "items": [...], "total": 1329.98, ...}

curl "http://127.0.0.1:8000/orders/1?detail_level=full"
# → {every field including internal_notes}

Exercise 6: Debugging response_model bugs (Hard)

The following code has 3 bugs related to response_model. Find them and fix them.

from fastapi import FastAPI
from pydantic import BaseModel
from typing import Optional

app = FastAPI()

class UserInDB(BaseModel):
    id: int
    name: str
    email: str
    password_hash: str
    role: str = "user"

class UserPublic(BaseModel):
    id: int
    name: str
    email: str

users = [
    UserInDB(id=1, name="Ana", email="ana@test.com", password_hash="abc123", role="admin"),
]

# Bug 1
@app.get("/users/{user_id}", response_model=UserPublic)
def get_user(user_id: int):
    user = next((u for u in users if u.id == user_id), None)
    return user  # what happens if user is None?

# Bug 2
@app.get("/users", response_model=UserInDB)
def list_users():
    return users  # is that the right response_model?

# Bug 3
@app.patch("/users/{user_id}", response_model=UserPublic)
def update_user(user_id: int, updates: dict):
    user = next((u for u in users if u.id == user_id), None)
    for key, value in updates.items():
        setattr(user, key, value)
    return user  # exclude_unset?
See solution

Bug 1: If user is None, FastAPI tries to serialize None with UserPublic and blows up with a 500 error. You need to check and raise HTTPException.

Bug 2: response_model=UserInDB on an endpoint that returns a list. It should be response_model=list[UserPublic] (not UserInDB — that exposes password_hash).

Bug 3: The PATCH doesn't check whether the user exists (if it's None, setattr fails), and it doesn't use response_model_exclude_unset — the response will include model defaults the client never sent.

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from typing import Optional

app = FastAPI()

class UserInDB(BaseModel):
    id: int
    name: str
    email: str
    password_hash: str
    role: str = "user"

class UserPublic(BaseModel):
    id: int
    name: str
    email: str

class UserUpdate(BaseModel):
    name: Optional[str] = None
    email: Optional[str] = None

users = [
    UserInDB(id=1, name="Ana", email="ana@test.com", password_hash="abc123", role="admin"),
]

# Fix 1: Check for None and raise 404
@app.get("/users/{user_id}", response_model=UserPublic)
def get_user(user_id: int):
    user = next((u for u in users if u.id == user_id), None)
    if user is None:
        raise HTTPException(status_code=404, detail="User not found")
    return user

# Fix 2: The right response_model (a list + the public schema)
@app.get("/users", response_model=list[UserPublic])
def list_users():
    return users

# Fix 3: Check for None + exclude_unset + a typed model
@app.patch(
    "/users/{user_id}",
    response_model=UserPublic,
    response_model_exclude_unset=True,
)
def update_user(user_id: int, updates: UserUpdate):
    user = next((u for u in users if u.id == user_id), None)
    if user is None:
        raise HTTPException(status_code=404, detail="User not found")
    update_data = updates.model_dump(exclude_unset=True)
    for key, value in update_data.items():
        setattr(user, key, value)
    return user

Summary

  • response_model_exclude hides specific fields — handy for hiding 2-3 sensitive fields
  • response_model_include defines which fields DO go out — safer when you want an explicit list
  • response_model_exclude_unset omits fields that weren't set — essential for a correct PATCH
  • Multiple schemas (Public, Admin, Summary) are the cleanest solution for different views
  • Schema hierarchy: TaskBaseTaskCreate, TaskPublic, TaskAdmin share the common fields
  • Union types document that an endpoint can return different shapes
  • A discriminator field with Literal helps tell the types in a Union apart
  • Pydantic v2's model_dump(include=..., exclude=...) controls fields programmatically
  • ConfigDict(from_attributes=True) lets you build models from ORM objects

Additional resources

  1. FastAPI - Response Model — The official tutorial with response_model_exclude and response_model_include
  2. FastAPI - Extra Models — Multiple models for one resource (UserIn, UserOut, UserInDB)
  3. Pydantic v2 - model_dump — Serialization with include, exclude, exclude_unset
  4. Pydantic v2 - Model Config — ConfigDict and its options
  5. FastAPI - Union types — Union in response_model
  6. OpenAPI Specification - Discriminator — Discriminator in schemas

Next capsule: Streaming and File Responses — You'll learn to export large datasets as CSV using StreamingResponse with generators, and to serve files with FileResponse.