Response models
Response models
You’ve spent Lessons 5 and 6 caring about input — what the client sends, how it’s parsed, how it’s validated. This lesson is about the other direction: output. What does your API send back, and how do you control its shape?
You might think this isn’t worth a whole lesson. The handler returns a dict; FastAPI converts it to JSON; done. But that simple flow has three problems we’ve already started to hit:
- What goes in isn’t what comes out. A user registers with a password; the response should never include the password. A task is created from a few fields; the response should include the generated ID and timestamp.
- Internal fields can leak. If your database row has an
internal_admin_flagorpassword_hashfield, returning that row directly leaks it to the client. Bugs like this have cost real companies real money. - The response shape is invisible. Without a declared output schema, the OpenAPI docs can’t show clients what to expect. Generated client SDKs don’t know what fields exist. The contract is only in your handler’s code.
The fix for all three is response models — declaring the output shape with a Pydantic class, just like you’ve been declaring the input shape. FastAPI takes care of filtering, documenting, and serialising. Your handler can stop worrying about what fields are safe to return.
The pattern
The whole technique is a single keyword argument on the decorator:
@app.post("/users", response_model=UserOut, status_code=201)
def create_user(user_in: UserIn):
...
return new_user
response_model=UserOut tells FastAPI:
- The response must conform to the
UserOutschema. - Anything in the returned value that isn’t in
UserOutis stripped out. - The OpenAPI docs should describe this endpoint’s response as
UserOut.
The handler can return whatever shape it likes — a dict, a database row, even a different Pydantic model. FastAPI runs the result through UserOut and only the fields declared there make it into the JSON.
The naming convention is the one we previewed in Lesson 5: ...In for what comes in, ...Out for what goes out. Many real codebases use this. Some use Create... and ...Public. The names don’t matter; the separation matters.
The classic example: hiding the password
Build a fresh demo. Start a new folder:
fastapi-responses/
└── main.py
Install the email dependency from last lesson if you haven’t:
pip install fastapi uvicorn 'pydantic[email]'
Open main.py and start with two models — one for input, one for output:
# main.py
from datetime import datetime, timezone
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field, EmailStr
app = FastAPI(title="Response models demo")
class UserIn(BaseModel):
"""What the client sends to create a user."""
email: EmailStr
name: str = Field(min_length=1, max_length=80)
password: str = Field(min_length=8, max_length=200)
class UserOut(BaseModel):
"""What the API sends back. No password ever."""
id: int
email: EmailStr
name: str
created_at: datetime
Compare the two:
| Field | UserIn |
UserOut |
|---|---|---|
email |
✓ required | ✓ returned |
name |
✓ required | ✓ returned |
password |
✓ required | ✗ never returned |
id |
✗ not in input | ✓ server-generated |
created_at |
✗ not in input | ✓ server-generated |
This asymmetry is normal. Inputs are what the client supplies. Outputs include server-side additions and exclude server-side secrets. The same shape rarely makes sense for both.
Now add the in-memory store and the route:
users: list[dict] = []
next_user_id = 1
@app.post("/users", response_model=UserOut, status_code=201)
def create_user(user_in: UserIn):
global next_user_id
# In a real app you'd hash the password — out of scope for this lesson.
new_user = {
"id": next_user_id,
"email": user_in.email,
"name": user_in.name,
"password": user_in.password, # raw — present in storage, hidden from response
"created_at": datetime.now(timezone.utc),
}
users.append(new_user)
next_user_id += 1
return new_user # `response_model` filters out `password` automatically
The new_user dict has five fields. The response will have four — the response_model=UserOut strips out password on the way out. The handler doesn’t have to remember to omit it; the schema enforces that.
Add the read routes too:
@app.get("/users", response_model=list[UserOut])
def list_users():
return users
@app.get("/users/{user_id}", response_model=UserOut)
def get_user(user_id: int):
for u in users:
if u["id"] == user_id:
return u
raise HTTPException(status_code=404, detail=f"User {user_id} not found")
response_model=list[UserOut] works for collection endpoints — every item in the returned list gets filtered through UserOut.
Run it and watch the password disappear
uvicorn main:app --reload
Create a user:
curl -X POST http://127.0.0.1:8000/users \
-H "Content-Type: application/json" \
-d '{"email": "alice@example.com", "name": "Alice Carter", "password": "sunshine123"}'
Response:
{
"id": 1,
"email": "alice@example.com",
"name": "Alice Carter",
"created_at": "2026-05-26T18:42:15.123456+00:00"
}
No password. Not in POST /users, not in GET /users, not in GET /users/1. The handler returned a dict containing the password; the response model filtered it out before serialisation.
Try peeking with GET /users:
curl http://127.0.0.1:8000/users
[
{
"id": 1,
"email": "alice@example.com",
"name": "Alice Carter",
"created_at": "2026-05-26T18:42:15.123456+00:00"
}
]
Same protection on the list endpoint.
This is the win: password filtering is enforced by the schema, not by remembering to write del new_user["password"] in every endpoint.
The defensive payoff
Here’s an experiment that drives the point home. Add this to your main.py temporarily, or stash it in a Python REPL:
# Simulate someone adding a sensitive field to the database row
users.append({
"id": 99,
"email": "leak@example.com",
"name": "Leak User",
"password": "must-never-appear",
"internal_admin_flag": True, # a surprise field that should NEVER leak
"created_at": datetime.now(timezone.utc),
})
Now:
curl http://127.0.0.1:8000/users/99
{
"id": 99,
"email": "leak@example.com",
"name": "Leak User",
"created_at": "2026-05-26T18:42:18.987654+00:00"
}
password is gone. internal_admin_flag is gone. The UserOut schema doesn’t mention them, so they get stripped — even though I never explicitly told FastAPI to hide them.
This is the difference between opt-out (return everything by default, remember to hide secrets) and opt-in (return only what’s listed in the schema). Opt-in is a one-way ratchet for security: a future developer adding a column to your model doesn’t accidentally expose it, because the response model is the only thing that controls visibility.
Tasks with the spread pattern
Now let’s fix the boilerplate from Lesson 5. Recall the ugly bit:
new_task = {
"id": next_id,
"title": task.title,
"completed": task.completed,
"priority": task.priority,
}
Every field of TaskIn copied by hand. Adding a field meant adding it in three places: the input model, the output model, and the row construction. Easy to forget.
Pydantic provides model_dump() — converts a model instance to a plain dict. Combined with Python’s ** unpacking, we can write:
new_task = {
"id": next_id,
**task_in.model_dump(),
"created_at": datetime.now(timezone.utc),
}
That single **task_in.model_dump() expands to every field of task_in. Add a new field to TaskIn, and it appears in new_task automatically — no second change needed.
Add the task models and route:
from typing import Literal
class TaskIn(BaseModel):
title: str = Field(min_length=1, max_length=120)
priority: int = Field(default=2, ge=1, le=5)
status: Literal["todo", "in_progress", "done"] = "todo"
class TaskOut(BaseModel):
id: int
title: str
priority: int
status: Literal["todo", "in_progress", "done"]
created_at: datetime
tasks: list[dict] = []
next_task_id = 1
@app.post("/tasks", response_model=TaskOut, status_code=201)
def create_task(task_in: TaskIn):
global next_task_id
new_task = {
"id": next_task_id,
**task_in.model_dump(),
"created_at": datetime.now(timezone.utc),
}
tasks.append(new_task)
next_task_id += 1
return new_task
@app.get("/tasks", response_model=list[TaskOut])
def list_tasks():
return tasks
Compare to the Lesson 5 handler. The new version is shorter and harder to break. There’s nothing to keep in sync: change TaskIn, the spread automatically picks up new fields.
Try it:
curl -X POST http://127.0.0.1:8000/tasks \
-H "Content-Type: application/json" \
-d '{"title": "Buy groceries", "priority": 1}'
{
"id": 1,
"title": "Buy groceries",
"priority": 1,
"status": "todo",
"created_at": "2026-05-26T18:42:21.654321+00:00"
}
Status defaults to "todo", ID and created_at came from the server, no password-like leaks possible because TaskOut is fully declared.
What model_dump() does and doesn’t do
A few useful notes about model_dump():
- It returns a plain dict with all the model’s fields as keys. Defaults are included. Use it whenever you need the model’s data as a regular Python dict.
- It does not include extra fields that the user might have sent. Pydantic stripped those during input parsing.
- It does include unset optional fields with their defaults. If you want to skip unset fields, use
model_dump(exclude_unset=True)— useful forPATCHendpoints, where you only want to update the fields the client actually sent. - Datetimes are kept as
datetimeobjects. They get converted to ISO strings during the FastAPI → JSON step, not bymodel_dump()itself. (If you callmodel_dump(mode="json")you get strings directly.)
Inspect the OpenAPI docs
Open http://127.0.0.1:8000/docs. Click POST /users. Two things have changed compared to Lesson 5:
- The request body schema is
UserIn(withpasswordrequired). - The success response (
201) is documented asUserOut(withidandcreated_at, no password).
Both schemas are listed at the bottom of the page under “Schemas” — UserIn, UserOut, TaskIn, TaskOut. Each one is a separate, named contract. Generated client code (TypeScript, Swift, etc.) will have separate types for each, mirroring your Python.
This separation is what makes APIs you ship feel professional. Anyone consuming your API knows exactly which fields they need to send and exactly which fields they’ll get back.
A note on databases (looking ahead)
In Lesson 8 we’ll move from in-memory dicts to a real database with SQLAlchemy. The pattern stays the same: SQLAlchemy returns model objects (e.g. User(id=1, email="...", password_hash="...")), and your response model filters those for you too.
FastAPI knows how to read attributes off any object — not just dicts. So a SQLAlchemy User row can be returned directly from a handler:
@app.get("/users/{user_id}", response_model=UserOut)
def get_user(user_id: int, db: Session = Depends(get_db)):
user = db.query(User).get(user_id)
return user # SQLAlchemy object — response_model handles it
For this to work cleanly with older versions of Pydantic you needed an orm_mode = True config; with modern Pydantic (v2 with FastAPI), it works out of the box. We’ll use this pattern in the mini-project next lesson.
Common pitfalls
- Forgetting
response_modeland accidentally returning a SQLAlchemy row. Withoutresponse_model, FastAPI tries to serialise the whole object, including private fields and password hashes. Always declare a response model on endpoints that return database rows. - Putting
idin the input model. The server generates IDs. Don’t accept them from the client — that lets clients overwrite each other’s records.UserInhas noid;UserOutdoes. - Using the same model for both directions. Sometimes it’s fine for very simple endpoints. More often it’s a trap — the field set drifts, and one direction has fields it shouldn’t.
- Adding fields to the output model that aren’t in your data. If
UserOutdeclarescreated_at: datetimebut your dict doesn’t include that key, FastAPI raises aResponseValidationError. The shape contract is enforced both ways. - Returning a single object when
response_model=list[X](or vice versa). The response model has to match the shape (list vs object), not just the element type. Mismatched shapes raise an error. - Manually copying fields when
**model_dump()would do. Stay vigilant about this. Anywhere you find yourself listing each field by hand, ask whether the spread pattern would work.
Summary
- Response models declare what your API sends back, using Pydantic classes —
UserOut,TaskOut. - Attach with
response_model=...on the route decorator. For lists, useresponse_model=list[X]. - Returned data is filtered to only the fields in the response model. Extra fields (passwords, hashes, internal flags) are stripped automatically.
- This is opt-in serialisation — only what you declare gets returned. A defensive, future-proof default.
- The naming convention
XxxInfor input andXxxOutfor output keeps the asymmetry visible. model_dump()converts a Pydantic instance to a plain dict; combined with**unpacking it eliminates field-by-field copying.- The OpenAPI docs show both schemas — every endpoint has a clearly described request and response shape.
Outcome
You can now control both directions of your API’s contract with confidence. Input shapes describe what you’ll accept; output shapes guarantee what you’ll return. Passwords stay private. Internal fields can’t leak. The OpenAPI docs reflect every shape cleanly, and the code that handles each route stays short and predictable.
Lessons 0 through 7 have built every piece in isolation — concepts in 0–1, individual demos in 2–7. Next lesson, the synthesis: a complete Books API that uses all seven techniques together, backed by a real database, with proper CRUD and error handling. That’s the mini-project.