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?
| Situation | Use |
|---|---|
| You want to hide 2-3 sensitive fields | response_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 them | response_model_exclude |
| Security: an explicit allowlist of fields | response_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— onlyid,title,author,publishedArticlePublic— everything excepteditor_notesandviews_countArticleAdmin— 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_excludehides specific fields — handy for hiding 2-3 sensitive fieldsresponse_model_includedefines which fields DO go out — safer when you want an explicit listresponse_model_exclude_unsetomits fields that weren't set — essential for a correct PATCH- Multiple schemas (Public, Admin, Summary) are the cleanest solution for different views
- Schema hierarchy:
TaskBase→TaskCreate,TaskPublic,TaskAdminshare the common fields Uniontypes document that an endpoint can return different shapes- A discriminator field with
Literalhelps 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
- FastAPI - Response Model — The official tutorial with response_model_exclude and response_model_include
- FastAPI - Extra Models — Multiple models for one resource (UserIn, UserOut, UserInDB)
- Pydantic v2 - model_dump — Serialization with include, exclude, exclude_unset
- Pydantic v2 - Model Config — ConfigDict and its options
- FastAPI - Union types — Union in response_model
- 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.