backend / web frameworks / fastapi / 17_response_model_advanced.md

response_model — Advanced Patterns

6 interview angles 5 min read source

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:

  1. Validates the return value against UserOut.
  2. Serializes it (dropping fields not in UserOut).
  3. 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 is None.
  • 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_model validates by default. Slow path on large lists. Disable with response_model_skip_validation=True (added in newer FastAPI) or skip response_model and return a Response.
  • exclude doesn’t combine with include. Pick one direction.
  • Pydantic v1 syntax bleeding in. orm_modefrom_attributes; Config inner class → model_config = ConfigDict(...).
  • Field defaults emitted as null. Set Optional types and use exclude_none=True if you want them out.
  • OpenAPI says one shape, you actually return another. Setting response_model to 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_model do?” — 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 main response_model covers 200; responses covers 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; was orm_mode in v1). Pydantic then reads attributes off the object.
  • “What’s computed_field?” — Pydantic v2 decorator that exposes a Python @property as 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 both A and B have a literal type field. Pydantic + OpenAPI handle it as a discriminated union; TypeScript clients narrow on type.
  • “How do you skip emitting null defaults in responses?”response_model_exclude_none=True or use Optional types + exclude_unset=True for “only emit fields the application set explicitly.”