response_model — Advanced Patterns
FastAPI’s response_model does more than schema docs. It controls serialization, filters fields, and lets you declare different return types per status code. The subtle behaviors matter.
Basics recap
class UserOut(BaseModel):
id: int
email: str
# internal_token NOT included → never leaks
@app.get("/users/{id}", response_model=UserOut)
async def get_user(id: int) -> User: # returns ORM User with internal_token
return await db.get_user(id)
FastAPI:
- Validates the return value against
UserOut. - Serializes it (dropping fields not in
UserOut). - Emits the OpenAPI schema as
UserOut.
Returning a Pydantic-or-not object works; FastAPI introspects fields.
Filtering: include / exclude
@app.get("/users/{id}", response_model=UserOut, response_model_exclude={"email"})
async def get_user(id: int):
...
@app.get("/users/{id}", response_model=UserOut, response_model_include={"id", "email"})
async def get_user(id: int):
...
exclude removes fields; include keeps only those. Same Pydantic kwargs you’d use in .model_dump().
Nested:
response_model_exclude={"orders": {"__all__": {"items"}}}
“Exclude items from every order in the orders list.” Verbose; usually cleaner to define a separate schema.
response_model_exclude_unset / _none / _defaults
class UserOut(BaseModel):
id: int
email: str
name: str | None = None
@app.get("/users/{id}", response_model=UserOut, response_model_exclude_unset=True)
exclude_unset=True— skip fields the caller didn’t explicitly set (default values not emitted).exclude_none=True— skip fields whose value isNone.exclude_defaults=True— skip fields equal to their default.
exclude_unset is the useful one for partial PATCH responses where you want to show only fields that actually changed.
Different response per status code
class UserOut(BaseModel):
id: int
email: str
class Error(BaseModel):
code: str
detail: str
@app.get(
"/users/{id}",
response_model=UserOut,
responses={
404: {"model": Error},
429: {"model": Error, "description": "Rate limited"},
503: {"model": Error},
},
)
async def get_user(id: int):
if not_found:
raise HTTPException(404, ...)
return user
responses={status: {"model": Schema}} declares per-status schemas — improves OpenAPI docs and gives clients typed error responses.
Returning Pydantic directly: bypass response_model
When you don’t want FastAPI to re-validate / filter, return a Response:
from fastapi.responses import JSONResponse
@app.get("/users/{id}")
async def get_user(id: int):
user = await db.get_user(id)
return JSONResponse(content=user.dict())
This skips the response_model serialization. Useful for: custom encoders, large payloads where re-validation is expensive, weird shapes the model doesn’t represent.
response_model and ORM objects
If your endpoint returns a SQLAlchemy model and response_model is a Pydantic schema, you need Pydantic to read attributes (not dict keys). In Pydantic v2:
class UserOut(BaseModel):
model_config = ConfigDict(from_attributes=True) # was orm_mode in v1
id: int
email: str
from_attributes=True lets Pydantic build the model from arbitrary objects with the named attributes.
Generic response wrappers
from typing import TypeVar, Generic
from pydantic import BaseModel
T = TypeVar("T")
class Page(BaseModel, Generic[T]):
items: list[T]
total: int
page: int
page_size: int
@app.get("/users", response_model=Page[UserOut])
async def list_users(page: int = 1, page_size: int = 20):
items, total = await db.list_users(page, page_size)
return Page(items=items, total=total, page=page, page_size=page_size)
Generic schemas work in Pydantic v2 + FastAPI; OpenAPI emits both Page_UserOut_ and Page_OrderOut_ shapes for each instantiation.
Union returns / discriminator
class CreditCardPayment(BaseModel):
type: Literal["card"]
last4: str
class BankTransferPayment(BaseModel):
type: Literal["bank"]
account: str
Payment = Annotated[CreditCardPayment | BankTransferPayment, Field(discriminator="type")]
@app.get("/payments/{id}", response_model=Payment)
async def get_payment(id: int):
...
The discriminator field tells Pydantic which member to use; OpenAPI emits a proper discriminated union; type-aware clients (TypeScript) consume it cleanly.
Skip validation but keep filtering: response_model_by_alias
class UserOut(BaseModel):
user_id: int = Field(alias="id")
email: str
@app.get("/users/{id}", response_model=UserOut, response_model_by_alias=False)
by_alias=False serializes using field names not aliases. Useful when input uses one naming, output uses another.
Common gotchas
- Returning extra fields that don’t exist on the schema — FastAPI drops them silently. Sometimes you want this (privacy filter); sometimes you wanted an error.
response_modelvalidates by default. Slow path on large lists. Disable withresponse_model_skip_validation=True(added in newer FastAPI) or skip response_model and return aResponse.excludedoesn’t combine withinclude. Pick one direction.- Pydantic v1 syntax bleeding in.
orm_mode→from_attributes;Configinner class →model_config = ConfigDict(...). - Field defaults emitted as null. Set
Optionaltypes and useexclude_none=Trueif you want them out. - OpenAPI says one shape, you actually return another. Setting
response_modelto a smaller schema than your function returns is intentional filtering, not an error. Helpful but confusing for clients who think they’re getting more.
Common patterns
Auth-scoped response
Return more fields to admins than to regular users:
@app.get("/users/{id}")
async def get_user(id: int, current_user = Depends(get_user)):
user = await db.get_user(id)
if current_user.is_admin:
return UserAdminOut.model_validate(user)
return UserOut.model_validate(user)
Skip response_model and validate manually based on context.
List endpoint with cursor pagination
class CursorPage(BaseModel, Generic[T]):
items: list[T]
next_cursor: str | None
@app.get("/orders", response_model=CursorPage[OrderOut])
async def list_orders(cursor: str | None = None, limit: int = 50):
items, next_cursor = await db.list_orders(cursor, limit)
return CursorPage(items=items, next_cursor=next_cursor)
Computed fields (Pydantic v2)
class OrderOut(BaseModel):
items: list[OrderItem]
subtotal: float
tax: float
@computed_field
@property
def total(self) -> float:
return self.subtotal + self.tax
@computed_field (v2) emits total in serialization and in OpenAPI as if it were a regular field. Read-only — never accepted as input.
Interview angle
- “What does
response_modeldo?” — validates the return value, serializes it (dropping fields not in the schema), and emits OpenAPI under that schema. Three jobs in one. - “How do you have different response shapes per status code?” —
responses={status: {"model": Schema}}. The mainresponse_modelcovers 200;responsescovers everything else. Improves typed clients and docs. - “How do you return a SQLAlchemy ORM object as a Pydantic schema?” — set
model_config = ConfigDict(from_attributes=True)(v2; wasorm_modein v1). Pydantic then reads attributes off the object. - “What’s
computed_field?” — Pydantic v2 decorator that exposes a Python@propertyas part of the serialized schema. Read-only. Useful for derived values (total = subtotal + tax) you don’t want to store but want in responses + OpenAPI. - “How do you do a discriminated union response?” —
Annotated[A | B, Field(discriminator="type")]where bothAandBhave a literaltypefield. Pydantic + OpenAPI handle it as a discriminated union; TypeScript clients narrow ontype. - “How do you skip emitting null defaults in responses?” —
response_model_exclude_none=Trueor useOptionaltypes +exclude_unset=Truefor “only emit fields the application set explicitly.”