response_model
FastAPI serialises whatever a path operation returns. If that is an ORM row or a Pydantic model of your database table, the response contains every field on it.
response_model is the declaration that narrows it. It is optional, its absence
produces no warning, and the endpoint works perfectly either way — which is why this
is a disclosure control rather than a bug.
Observed
Section titled “Observed”One model with four fields, returned from two endpoints that differ only in whether
response_model is set:
class UserDB(BaseModel): id: int email: str hashed_password: str is_admin: bool
class UserOut(BaseModel): id: int email: str
@app.get("/with", response_model=UserOut)def with_model(): return row
@app.get("/without")def without_model(): return rowGET /with -> {"id": 1, "email": "a@b.c"}GET /without -> {"id": 1, "email": "a@b.c", "hashed_password": "$2b$12$...", "is_admin": true}Both return 200. Both are “working endpoints”. One of them publishes the password hash and the authorisation flag.
response_modeloptionDeclares what the endpoint may return, and filters everything else out. The default is None, which means no filtering and no warning — the failure is silent and looks like a working endpoint. Reproduced: the same row returned {id, email} with a model and {id, email, hashed_password, is_admin} without one, both 200.
- accepts
a Pydantic model|None- default
None- set in
@app.get(...) / @app.post(...) decorator
-> ReturnTypeoptionA return annotation filters exactly as response_model does. That makes it the sharp edge here: changing a type hint changes what the endpoint returns over the wire, so a disclosure change can arrive disguised as a typing cleanup and pass review as one. Reproduced returning the same filtered {id, email}.
- accepts
a Pydantic model as the return annotation- set in
the path function signature- since
FastAPI 0.89
from pydantic import BaseModel
class UserOut(BaseModel): id: int email: str
@app.get("/users/{user_id}", response_model=UserOut)def read_user(user_id: int) -> UserOut: return repo.get(user_id) # extra fields are dropped, not returnedDeclare a separate output model. Not the database model with fields excluded — a model that names what the endpoint returns.
# every key your API can emit, straight from the generated schemacurl -s https://api.example.com/openapi.json \ | python3 -c 'import json,sys; s=json.load(sys.stdin)["components"]["schemas"]; [print(n, sorted(v.get("properties",{}))) for n,v in sorted(s.items())]'Read that list for anything a client should not see — hashed_password,
is_staff, internal_notes, api_key. The schema is generated from your models, so
this shows what the application will serialise rather than what one sampled response
happened to contain.
Then find the endpoints that declare nothing:
grep -rnE '@(app|router)\.(get|post|put|patch|delete)\(' app/ \ | grep -v 'response_model'Every hit is an endpoint whose output shape is whatever the handler happens to return.
The return annotation counts too
Section titled “The return annotation counts too”Since FastAPI 0.89 a return type annotation is used as the response model when
response_model is not passed, so this filters as well:
@app.get("/users/{user_id}")def read_user(user_id: int) -> UserOut: ...That is worth knowing in both directions. It means a correctly annotated codebase may
already be filtering — and it means changing an annotation changes what the endpoint
returns. Widening -> UserOut to -> UserDB during a refactor is a disclosure
change disguised as a typing change, and no test that only asserts on status_code
will notice.
response_model wins when both are present, and response_model=None disables the
behaviour entirely.
Why excluding fields is the weaker pattern
Section titled “Why excluding fields is the weaker pattern”response_model_exclude={"hashed_password"} works, and it is the wrong default habit,
because it is a deny-list. The set of fields you remembered to exclude is fixed at the
moment you wrote it; the set of fields on the model is not.
Add a column to the table, add the field to the model, and an allow-list model keeps returning what it always returned. A deny-list starts publishing the new field immediately.
Prefer a small explicit output model. Reach for response_model_exclude only where
one endpoint needs a one-off variation.
Adding response_model to an endpoint that previously returned everything removes
fields from responses, and clients depending on those fields break — including your
own front end, and including fields nobody realised were being consumed.
The failure is a KeyError or an undefined property in the client, not an error in
your logs, so it surfaces as a UI bug hours later.
Before narrowing an existing endpoint, find out what is actually consumed rather than what is documented. Search the front end for the field names:
grep -rnoE '\.(hashed_password|is_admin|internal_notes)\b' frontend/src/ | sort -uFor a public API, the safer sequence is to add the output model with the current field set, ship that, then remove fields one at a time behind a version — so each removal is individually revertible.
Strict validation is the second surprise: if the handler returns something the output model cannot validate, FastAPI raises rather than silently coercing, turning a previously-working endpoint into a 500. Run your integration tests against the annotated endpoints before deploying.
Related
Section titled “Related”- Validation error disclosure — the other place model shapes reach the client
- Docs endpoints — where the schema this page relies on is published