response_model tells FastAPI which Pydantic model to use for validating and
filtering the handler's return value before serialising it to JSON.
class UserIn(BaseModel):
name: str
password: str # sensitive — never leak this
class UserOut(BaseModel):
id: int
name: str # no password field
@app.post("/users", response_model=UserOut)
async def create_user(user: UserIn) -> UserOut:
db_user = await db.create(user)
return db_user # password field is stripped by response_model
FastAPI:
- Validates the return value against
UserOut. - Strips fields not in
UserOut(e.g.,password). - Documents
UserOutas the response schema in OpenAPI.
Rule of thumb: always set response_model on any endpoint that returns ORM
objects or models with sensitive fields.
Both tell FastAPI the response shape, but response_model= overrides the
return annotation for serialisation and filtering:
# return annotation only — used for schema AND serialisation
@app.get("/users/{id}")
async def get_user(id: int) -> UserOut:
return await db.get(User, id)
# response_model wins — annotation is for type checkers only
@app.get("/users/{id}", response_model=UserOut)
async def get_user(id: int) -> User: # mypy sees User; FastAPI uses UserOut
return await db.get(User, id)
Use the return annotation when they're the same type (clean, Pythonic).
Use response_model= when the serialised type differs from what the handler
actually returns (ORM model → Pydantic output DTO).
Rule of thumb: prefer the return annotation; add response_model= only when
you need to filter or transform the handler's return value.
Set response_model_exclude_none=True in the route decorator:
class Report(BaseModel):
total: int
average: float | None = None
notes: str | None = None
@app.get("/report", response_model=Report, response_model_exclude_none=True)
async def get_report():
return {"total": 100}
# average and notes are None → omitted: {"total": 100}
Rule of thumb: use exclude_none=True for sparse responses where None
means "not applicable" — keeps the JSON payload small and clean.
response_model_exclude_unset=True strips fields that were not explicitly
set in the return value — it distinguishes between "not set" and "set to None".
class Item(BaseModel):
name: str
description: str | None = None
price: float | None = None
@app.patch("/items/{id}", response_model=Item, response_model_exclude_unset=True)
async def patch_item(id: int, patch: Item):
existing = await db.get(id)
updated_data = patch.model_dump(exclude_unset=True)
existing.update(updated_data)
return existing
This is critical for PATCH semantics: you only return (and store) fields the client actually sent, not defaults.
Rule of thumb: use exclude_unset=True on PATCH endpoints — it lets clients
send partial updates without overwriting fields they didn't mention.
Use response_model_exclude={"field1", "field2"} in the route decorator:
class User(BaseModel):
id: int
name: str
password_hash: str
internal_notes: str | None = None
@app.get(
"/users/{id}",
response_model=User,
response_model_exclude={"password_hash", "internal_notes"},
)
async def get_user(id: int):
return await db.get(id)
This avoids creating a separate UserOut model for simple exclusion cases.
For complex filtering (renames, computed fields), a dedicated output model is cleaner.
Rule of thumb: use response_model_exclude for 1-2 fields; create a dedicated
output model when you exclude three or more fields or need renames/aliases.
Return JSONResponse when you need to:
- Set a non-default status code dynamically.
- Set custom response headers.
- Bypass
response_modelfiltering (e.g., a pre-serialised payload).
from fastapi.responses import JSONResponse
@app.get("/items/{id}")
async def get_item(id: int):
item = await db.get(id)
if not item:
return JSONResponse(
status_code=404,
content={"detail": "Not found"},
headers={"X-Request-ID": "abc123"},
)
return item # normal path uses response_model
JSONResponse bypasses Pydantic serialisation — pass JSON-safe Python primitives
or use jsonable_encoder() first.
Rule of thumb: return dicts/models 95% of the time; reach for JSONResponse
only when you need direct control over status code or headers.
| Class | Use case |
|---|---|
JSONResponse (default) |
JSON API responses |
HTMLResponse |
Rendered HTML pages |
PlainTextResponse |
Plain text, logs |
RedirectResponse |
301/302/307 redirects |
FileResponse |
Send a file with proper headers |
StreamingResponse |
Large responses, real-time data |
ORJSONResponse |
Faster JSON via orjson |
UJSONResponse |
Faster JSON via ujson |
from fastapi.responses import FileResponse, StreamingResponse
@app.get("/download")
async def download():
return FileResponse("report.pdf", filename="report.pdf")
@app.get("/stream")
async def stream():
async def generate():
for chunk in large_data():
yield chunk
return StreamingResponse(generate(), media_type="text/plain")
Rule of thumb: set default_response_class=ORJSONResponse on the FastAPI()
instance to get faster serialisation across all endpoints with no other changes.
orjson is a Rust-backed JSON library that is 5-10× faster than the stdlib
json module. Install it with pip install orjson, then:
from fastapi import FastAPI
from fastapi.responses import ORJSONResponse
# Make it the default for all routes
app = FastAPI(default_response_class=ORJSONResponse)
@app.get("/items")
async def list_items():
return [{"id": 1}, {"id": 2}]
Or per-route:
@app.get("/items", response_class=ORJSONResponse)
async def list_items():
...
orjson natively handles datetime, UUID, bytes and NumPy arrays without
jsonable_encoder.
Rule of thumb: switch to ORJSONResponse globally for any throughput-sensitive
API — it's a one-line change with measurable latency improvement.
Return a StreamingResponse with an async (or sync) generator:
from fastapi.responses import StreamingResponse
import asyncio
async def generate_csv():
yield "id,name\n"
async for row in db.stream_all_users():
yield f"{row.id},{row.name}\n"
@app.get("/export/users.csv")
async def export_users():
return StreamingResponse(
generate_csv(),
media_type="text/csv",
headers={"Content-Disposition": "attachment; filename=users.csv"},
)
The client receives bytes as they arrive; the server never holds the full dataset in memory.
Rule of thumb: use StreamingResponse for exports, large file downloads, and
server-sent events — never load a 1M-row dataset into a list before sending.
Return a RedirectResponse:
from fastapi.responses import RedirectResponse
from fastapi import status
@app.get("/old-path")
async def old_path():
return RedirectResponse(
url="/new-path",
status_code=status.HTTP_301_MOVED_PERMANENTLY,
)
# Temporary redirect (default 307)
@app.get("/login")
async def login_redirect():
return RedirectResponse(url="/auth/login")
Default status code for RedirectResponse is 307 Temporary Redirect, which
preserves the HTTP method. Use 301 for permanent SEO-friendly redirects, 302 for
temporary, 303 to force GET on redirect.
Rule of thumb: use 307 to preserve POST method on redirect; use 303 to convert a POST response to a GET (Post/Redirect/Get pattern).
Use response_model_include={"field1", "field2"}:
class User(BaseModel):
id: int
name: str
email: str
phone: str | None = None
internal_id: str
@app.get(
"/users/{id}/public",
response_model=User,
response_model_include={"id", "name"}, # only these two fields
)
async def get_user_public(id: int):
return await db.get(id)
# response: {"id": 42, "name": "Alice"}
Rule of thumb: prefer response_model_exclude for "strip secrets" use cases and
a dedicated output model for complex projections — response_model_include with
many field names becomes hard to maintain.
Inject the Response object into the handler and set headers on it:
from fastapi import Response
@app.get("/items")
async def list_items(response: Response):
items = await db.all()
response.headers["X-Total-Count"] = str(len(items))
response.headers["Cache-Control"] = "max-age=60"
return items # normal JSON response with extra headers
You can also set headers via JSONResponse(headers={...}) or middleware.
Injecting Response is the cleanest option when the route already returns a
plain dict/model.
Rule of thumb: inject Response for per-request headers (pagination counts,
rate-limit info); use middleware for headers that apply to all responses (CORS, CSP).
Inject BackgroundTasks and add tasks before returning:
from fastapi import BackgroundTasks
def notify_admin(item_id: int):
send_email("admin@example.com", f"New item {item_id} created")
@app.post("/items", status_code=201)
async def create_item(item: Item, background_tasks: BackgroundTasks):
new_item = await db.create(item)
background_tasks.add_task(notify_admin, new_item.id)
return new_item # response is sent immediately; email sent after
FastAPI sends the HTTP response, then runs the background task(s) sequentially. The client gets its 201 Created without waiting for the email.
Rule of thumb: use BackgroundTasks for fast, non-retryable work; use a task
queue (Celery, ARQ) for anything that must succeed or be retried.
More Routing & Parameters interview questions
More ways to practice
The self-quiz is live. Get notified when mock interviews and new question packs drop.