Skip to content
Beyond the Stack

No Persian translation yet.

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.

Published 4 min read

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:

Handler return value passing through response_model validation, encoding, and a response class before reaching the socket
The four stages between your return statement and the socket.

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.