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:

  1. Calls the handler.
  2. Takes the return value (could be a SQLAlchemy model, dict, Pydantic instance, etc.).
  3. Validates / shapes it into UserOut.
  4. 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 .