Inside FastAPI · Part 3/3
How FastAPI serialises a response
Your endpoint returns a Python object. Between that object and the bytes on the socket sit validation, encoding, and a response class that decides how the body is written. Each step has a cost and a failure mode.
The endpoint returned a dictionary. A few milliseconds later the client holds JSON. In between, FastAPI validated the value against response_model, converted it to JSON-compatible primitives, encoded it, and handed a byte string to the ASGI server. Each of those steps has a cost and a failure mode, and each one is invisible in a handler that only returns an object.
The shape of the path
After the handler returns, the request is not finished. FastAPI still has to turn the return value into something the ASGI server can write. The path has four stages:
Stages one and two are optional in the sense that you can omit response_model, but the work still happens. FastAPI never writes arbitrary Python objects to the wire; it always chooses an encoder.
Stage one: validate and filter
When a route declares response_model, FastAPI validates the return value against that model before anything is written. Validation is not just a type check; it decides which fields survive. A field that exists on the ORM object but not in the model is dropped.
class UserOut(BaseModel):
id: int
email: str
created_at: datetime
@app.get("/users/{user_id}", response_model=UserOut)
async def read_user(user_id: int) -> User:
return await users.get(user_id)
That endpoint can return a full User row with a hashed password, and the password never reaches the response. The model is doing security work disguised as typing.
There is a subtlety with inheritance. If UserOut extends a model that includes password_hash, the field is back. Response models compose, so audit the whole chain rather than the route signature. The same applies to response_model_exclude_unset, which is useful for PATCH responses but can surprise clients that expect stable keys.
@app.patch("/users/{user_id}", response_model=UserOut, response_model_exclude_unset=True)
async def update_user(user_id: int, patch: UserPatch) -> User:
return await users.apply(user_id, patch)
Now an update that only changes email returns {"id": ..., "email": ...} and omits created_at. That is often what you want; it is never what an untyped client expects by default.
Stage two: encode to JSON-compatible data
After validation, FastAPI converts the model to primitives. With a response model it calls Pydantic’s serializer in JSON mode. Without one it calls jsonable_encoder, which walks dictionaries, lists, datetimes, UUIDs, enums, and ORM objects recursively.
# No response_model: the encoder walks the whole object graph.
@app.get("/debug/state")
async def debug_state() -> dict:
return {"users": await users.all(), "queues": queues.snapshot()}
jsonable_encoder is forgiving, which is exactly why it is easy to ship accidentally expensive responses. A deeply nested graph becomes many small dictionary allocations before a single byte is written. It also converts types silently: a Decimal becomes a string or a float depending on the path, and a set becomes a list in an order you did not choose.
Stage three: pick a response class
The final step is a Response subclass. The common ones differ in more than speed:
| Class | Body | Content-Length |
Use when |
|---|---|---|---|
JSONResponse |
json.dumps, UTF-8 |
yes | Default, small payloads |
ORJSONResponse |
orjson | yes | Hot paths with large JSON |
PlainTextResponse |
text | yes | Health checks, simple errors |
StreamingResponse |
chunks | no | Logs, exports, server-sent events |
FileResponse |
OS sendfile | yes | Static or generated files |
from fastapi.responses import ORJSONResponse
app = FastAPI(default_response_class=ORJSONResponse)
Two details matter in production. First, json.dumps is synchronous and runs on the event loop; a 20 MB payload stalls that worker for every concurrent request. Second, middleware such as gzip runs after the body exists, so compression does not save you from building it. If the body is large, stream it and compress at the edge.
@app.get("/export")
async def export() -> StreamingResponse:
async def rows():
async for row in db.stream_orders():
yield json.dumps(row) + "\n"
return StreamingResponse(rows(), media_type="application/x-ndjson")
Stage four: the socket
The ASGI server receives http.response.start and http.response.body messages and writes them. Once you are here, the only remaining choices are transport-level: keep-alive, TLS, and backpressure. A client that stops reading applies backpressure through the server, which is why unbounded streaming responses need their own limits.
What to check in your own service
- Every route that returns domain objects declares a response model.
- Large or unbounded collections are paginated before they reach the encoder.
- Response classes are chosen deliberately, not inherited by accident.
- Serialization time appears in your traces, not only total request time.
- A payload test asserts the shape clients depend on, not just the status code.
The request path is now complete: socket, ASGI, routing, dependencies, handler, and response. The next series looks at the patterns that keep this shape maintainable as the number of routes grows.