Chapter 4: outputs. How FastAPI takes whatever your handler returns and turns it into bytes on the wire. Response models, serialization, custom encoders, response classes, status codes, headers, and cookies.
response_model
class UserOut(BaseModel):
id: int
email: EmailStr
full_name: str
@app.get("/users/{id}", response_model=UserOut)
async def get_user(id: int):
user = await db.get_user(id)
return user
FastAPI:
- Calls the handler.
- Takes the return value (could be a SQLAlchemy model, dict, Pydantic instance, etc.).
- Validates / shapes it into
UserOut. - Serializes to JSON.
The contract is the response model — caller can rely on the shape.
response_model_exclude / include
@app.get("/users/{id}", response_model=UserOut, response_model_exclude={"email"})
Excludes specific fields from output. Or response_model_include={...}.
Useful when one model serves multiple visibility levels.
response_model_exclude_none / unset / defaults
@app.get("/users/{id}", response_model=UserOut, response_model_exclude_none=True)
exclude_none: drop fields with None value.
exclude_unset: drop fields not explicitly set.
exclude_defaults: drop fields equal to their default.
For PATCH-style partial responses: useful.
Pydantic on the way out
Even without response_model, FastAPI serializes Pydantic models you return. With response_model:
- Filters / shapes per the declared model.
- Generates accurate OpenAPI.
- Strips fields not in the response model (avoids accidental leaks).
Always declare response_model for typed APIs.
from_attributes
For SQLAlchemy → Pydantic conversion:
class UserOut(BaseModel):
id: int
email: str
model_config = {"from_attributes": True}
FastAPI / Pydantic now reads user.id, user.email from the SQLAlchemy instance.
Custom encoders
Pydantic v2 handles most types natively. For custom types:
from pydantic import field_serializer
class Event(BaseModel):
when: datetime
@field_serializer("when")
def serialize_when(self, v: datetime, _info):
return v.isoformat()
field_serializer for one field, model_serializer for the whole model.
Datetime serialization
Default: ISO 8601 with timezone if aware. For Unix timestamps:
class Event(BaseModel):
when: datetime
@field_serializer("when")
def to_unix(self, v):
return int(v.timestamp())
Match what your clients expect; document it.
Response status code
@app.post("/users", response_model=UserOut, status_code=201)
async def create_user(user: UserCreate):
...
201 for creation; 204 for delete (no body); 200 default. Override per route.
Setting headers / cookies
from fastapi import Response
@app.post("/login")
async def login(response: Response, ...):
response.set_cookie("session", token, httponly=True, secure=True, samesite="lax")
response.headers["x-custom"] = "value"
return {"ok": True}
Response injected into the handler; set headers/cookies before returning.
For full control: return a Response directly (see below).
Response classes
@app.get(..., response_class=...) controls the response type.
JSONResponse (default)
from fastapi.responses import JSONResponse
return JSONResponse({"hello": "world"})
Standard JSON. The default for return values.
ORJSONResponse / UJSONResponse
from fastapi.responses import ORJSONResponse
app = FastAPI(default_response_class=ORJSONResponse)
orjson is significantly faster than the stdlib JSON encoder. Set as default for hot APIs.
HTMLResponse
from fastapi.responses import HTMLResponse
@app.get("/", response_class=HTMLResponse)
async def home():
return "<h1>Hello</h1>"
For server-rendered HTML.
PlainTextResponse
from fastapi.responses import PlainTextResponse
@app.get("/health", response_class=PlainTextResponse)
async def health():
return "ok"
RedirectResponse
from fastapi.responses import RedirectResponse
@app.get("/old-path")
async def old():
return RedirectResponse(url="/new-path", status_code=301)
301 (permanent), 302 (temporary), 307 (preserve method), 308 (permanent + preserve method).
StreamingResponse
from fastapi.responses import StreamingResponse
@app.get("/stream")
async def stream():
async def gen():
for i in range(10):
yield f"chunk {i}\n".encode()
return StreamingResponse(gen(), media_type="text/plain")
For SSE, large files, NDJSON. See FastAPI Streaming .
FileResponse
from fastapi.responses import FileResponse
@app.get("/download/{name}")
async def download(name: str):
return FileResponse(f"/files/{name}", filename=name)
Streams a file from disk efficiently.
Returning Pydantic vs dict
Both work; Pydantic gets you OpenAPI accuracy, validation, exclusion options.
# OK
return {"id": 1, "name": "alice"}
# Better
return UserOut(id=1, name="alice")
For typed APIs: prefer Pydantic.
Handling errors
from fastapi import HTTPException
@app.get("/items/{id}")
async def get_item(id: int):
item = await db.get_item(id)
if not item:
raise HTTPException(status_code=404, detail="not found")
return item
HTTPException is converted to a JSON error response. detail can be string or dict.
For richer error envelopes: custom exception handlers.
class AppError(Exception):
def __init__(self, code, message, status=400):
self.code = code
self.message = message
self.status = status
@app.exception_handler(AppError)
async def app_error_handler(request, exc):
return JSONResponse(status_code=exc.status, content={"error": exc.code, "message": exc.message})
Pagination response
class Page[T](BaseModel):
items: list[T]
next_cursor: str | None
has_more: bool
@app.get("/users", response_model=Page[UserOut])
async def list_users(cursor: str | None = None):
items, next_cursor = await load_page(cursor)
return Page(items=items, next_cursor=next_cursor, has_more=next_cursor is not None)
Generic Pydantic model. Reusable across endpoints.
Response in OpenAPI
Multiple responses for an endpoint:
@app.get(
"/users/{id}",
response_model=UserOut,
responses={
404: {"description": "User not found", "model": ErrorResponse},
429: {"description": "Rate limited"},
},
)
async def get_user(id: int): ...
OpenAPI documents all of them. Swagger UI shows them.
Compression
from fastapi.middleware.gzip import GZipMiddleware
app.add_middleware(GZipMiddleware, minimum_size=1000)
Gzip responses larger than 1KB. Set up at the LB layer instead in production usually.
Cache headers
@app.get("/posts")
async def list_posts(response: Response):
response.headers["Cache-Control"] = "public, max-age=60"
return await db.list_posts()
Or middleware that applies them based on path.
ETag / If-None-Match
@app.get("/users/{id}")
async def get_user(id: int, if_none_match: str | None = Header(None)):
user = await db.get_user(id)
etag = compute_etag(user)
if if_none_match == etag:
return Response(status_code=304)
return JSONResponse(user.model_dump(), headers={"ETag": etag})
For caching efficiency. Compute etag from a hash of the data.
Common mistakes
1. Leaking sensitive fields
return user where user has a password_hash. Always use response_model to filter.
2. Not setting response_model
OpenAPI shows the response as Any. Clients can’t generate types.
3. Mixing return types per endpoint
Sometimes returns a dict, sometimes a Pydantic model. Inconsistent shape. Stick to response_model.
4. Streaming without setting media_type
Browsers may misinterpret. Always set.
5. Big JSON in memory
Loading 100k records into memory then serializing. Stream with NDJSON / pagination.
What’s next
Chapter 5: Dependency Injection in depth.
Read this next
Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .