π― What You Will Learn
Your endpoint returns whatever object you hand it β and by default that includes the password hash. A response_model is FastAPI's allowlist for output: you declare the exact shape the client receives, and everything else is filtered out.
- Why returning a DB model directly is a security bug (see the leak first)
- Fixing it with
response_modelβ and how it works under the hood - The
XCreate/XReadinput vs output schema pattern response_model_exclude/includefor per-route trimmingexclude_unset,exclude_none,exclude_defaults- Nested + list response models, computed fields, and camelCase aliases
Prerequisites: a working FastAPI app and comfort with Pydantic schemas (python-fast-api-schema). We'll use SQLModel for a realistic DB model, but the response-model ideas apply to any object you return.
fastapi-response-models/
βββ requirements.txt
βββ app/
βββ __init__.py
βββ database.py # SQLite engine + get_session
βββ models.py # SQLModel table: User (has hashed_password)
βββ schemas.py # Pydantic I/O: UserCreate, UserRead, ...
βββ main.py # endpoints with response_modelThe Problem: Your API Is Leaking Data (See It First)
Here's a totally normal-looking User table and a "get user" endpoint. Nothing feels wrong β but ship this and you have a security incident.
from sqlmodel import SQLModel, Field
class User(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
email: str = Field(index=True, unique=True)
full_name: str
hashed_password: str # π΄ must NEVER leave the server
is_superuser: bool = False # π΄ internal flag, not the client's businessfrom fastapi import FastAPI, HTTPException, status
from app.database import get_session
from app.models import User
app = FastAPI(title="FastAPI Response Models", version="0.1.0")
@app.get("/users/{user_id}")
def get_user(user_id: int, session=Depends(get_session)):
user = session.get(User, user_id)
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
return user # π± returns the WHOLE rowCall it, and this is what the client sees:
$ curl -s http://127.0.0.1:8000/users/1
{
"id": 1,
"email": "[email protected]",
"full_name": "Ada Lovelace",
"hashed_password": "$2b$12$eImiTXuWVxfM37uY4JANjQ...", <-- LEAKED
"is_superuser": true <-- LEAKED
}The Fix: response_model as an Allowlist
Define a separate Pydantic model describing exactly what the client is allowed to see, then pass it as response_model. FastAPI runs your return value through that model on the way out.
from pydantic import BaseModel, ConfigDict, EmailStr
class UserRead(BaseModel):
# from_attributes lets Pydantic read straight off the ORM object
model_config = ConfigDict(from_attributes=True)
id: int
email: EmailStr
full_name: str
# note: no hashed_password, no is_superuserfrom app.schemas import UserRead
@app.get("/users/{user_id}", response_model=UserRead)
def get_user(user_id: int, session: SessionDep) -> UserRead:
user = session.get(User, user_id)
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
return user # still return the ORM object β FastAPI filters itSame request, safe response:
$ curl -s http://127.0.0.1:8000/users/1
{
"id": 1,
"email": "[email protected]",
"full_name": "Ada Lovelace"
}UserRead from your object and serializes that. Fields not on UserRead simply never make it into the JSON.How response_model Actually Works
response_model isn't just a filter. On every response it does three jobs:
| Job | What it means for you |
|---|---|
| Filter | Only fields declared on the model are serialized β an allowlist, not a blocklist. |
| Validate & coerce | Output is validated against the schema; wrong types are caught before they reach the client. |
| Document | The exact response shape appears in /docs and the OpenAPI schema, so clients know what to expect. |
User table later (say reset_token) will not leak β it isn't on UserRead, so it never ships. That "secure by default going forward" property is the whole point.-> UserRead) also sets the response model in modern FastAPI. If you use both and they disagree, the explicit response_model= argument wins. Pick one style and stay consistent β this series uses explicit response_model= for clarity.Input vs Output Schemas (Create / Read / Update)
The data coming in is not the data going out. A signup sends a plain password; the response must never echo it. That's why we keep separate schemas per direction β the XCreate / XRead / XUpdate convention used across this whole series.
class UserCreate(BaseModel):
email: EmailStr
full_name: str
password: str # plain text IN β never stored as-is
class UserRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
email: EmailStr
full_name: str
class UserUpdate(BaseModel):
# every field optional β this powers partial (PATCH) updates
full_name: str | None = None
email: EmailStr | None = None@app.post("/users", response_model=UserRead, status_code=status.HTTP_201_CREATED)
def create_user(payload: UserCreate, session: SessionDep) -> UserRead:
user = User(
email=payload.email,
full_name=payload.full_name,
hashed_password=hash_password(payload.password), # hash, don't store plain
)
session.add(user)
session.commit()
session.refresh(user)
return user # response_model strips it back down to UserReadhash_password() here is a stand-in. In production use a real KDF like bcrypt/argon2 β see fastapi-jwt for the proper hashing setup. Never store or return a plain password.password) and output safety (no password at all) have opposite rules. One model can't serve both without leaking or over-requiring.Trimming Per Route: response_model_exclude & include
Sometimes you want the same schema but a slimmer payload on one specific route β no need to define a new model. Use response_model_exclude (drop these fields) or response_model_include (keep only these).
# A public profile: reuse UserRead but hide the email on this route only
@app.get(
"/users/{user_id}/public",
response_model=UserRead,
response_model_exclude={"email"},
)
def public_profile(user_id: int, session: SessionDep) -> UserRead:
user = session.get(User, user_id)
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
return user$ curl -s http://127.0.0.1:8000/users/1/public
{
"id": 1,
"full_name": "Ada Lovelace"
}exclude/include for a one-off tweak. If two endpoints consistently return different shapes, define two schemas instead β it's clearer in /docs and less magic for the next developer.exclude_unset, exclude_none & exclude_defaults
These three control whether "empty-ish" fields appear in the JSON. They matter most for partial updates and sparse responses, and people mix them up constantly. Here's the difference:
| Option | Drops a field when⦠| Use it for |
|---|---|---|
exclude_unset | the field was never explicitly set on the model instance | Echoing back only what the client actually sent (PATCH) |
exclude_none | its value is None | Hiding null fields to keep payloads small |
exclude_defaults | its value still equals the schema default | Sending only fields that differ from defaults |
A partial update reads best with exclude_unset on the input side, so you only touch fields the client sent:
@app.patch("/users/{user_id}", response_model=UserRead)
def update_user(user_id: int, payload: UserUpdate, session: SessionDep) -> UserRead:
user = session.get(User, user_id)
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
# only the fields the client actually sent -> no accidental overwrites
updates = payload.model_dump(exclude_unset=True)
for field, value in updates.items():
setattr(user, field, value)
session.add(user)
session.commit()
session.refresh(user)
return userAnd on the output side you can drop nulls for a specific route with response_model_exclude_none:
@app.get("/users/{user_id}/compact", response_model=UserRead, response_model_exclude_none=True)
def compact_user(user_id: int, session: SessionDep) -> UserRead:
user = session.get(User, user_id)
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
return user # any field that is None simply won't appearexclude_unset keys off "was this explicitly set," not "is it None." A field set to None on purpose is still "set" and will appear β use exclude_none if you want it gone. They are not interchangeable.Nested & List Response Models
Response models compose. Return a list[UserRead] for collections, and nest a schema inside another to shape related data β each nested model does its own filtering.
class PostRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
title: str
class UserWithPosts(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
full_name: str
posts: list[PostRead] = [] # nested list β PostRead filters each postfrom app.schemas import UserRead, UserWithPosts
# a list endpoint: response_model is a list of the schema
@app.get("/users", response_model=list[UserRead])
def list_users(session: SessionDep) -> list[UserRead]:
return session.exec(select(User)).all()
# a nested endpoint: user + their posts, each shaped by its own model
@app.get("/users/{user_id}/detail", response_model=UserWithPosts)
def user_detail(user_id: int, session: SessionDep) -> UserWithPosts:
user = session.get(User, user_id)
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
return userPost row has an internal author_ip column, PostRead only exposes id and title, so the nested output is safe too β the allowlist applies at every level.Computed Fields & camelCase Aliases
Two more output tools your frontend will love. @computed_field adds a derived value that isn't a real column, and aliases let you emit camelCase JSON while keeping snake_case in Python.
from pydantic import BaseModel, ConfigDict, EmailStr, computed_field
from pydantic.alias_generators import to_camel
class UserProfile(BaseModel):
model_config = ConfigDict(
from_attributes=True,
alias_generator=to_camel, # full_name -> fullName in JSON
populate_by_name=True, # still accept snake_case when parsing
)
id: int
full_name: str
email: EmailStr
@computed_field # type: ignore[prop-decorator]
@property
def display_name(self) -> str:
return f"{self.full_name} <{self.email}>"# response_model_by_alias=True (the default) emits the aliased keys
@app.get("/users/{user_id}/profile", response_model=UserProfile)
def profile(user_id: int, session: SessionDep) -> UserProfile:
user = session.get(User, user_id)
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
return user$ curl -s http://127.0.0.1:8000/users/1/profile
{
"id": 1,
"fullName": "Ada Lovelace",
"email": "[email protected]",
"displayName": "Ada Lovelace <[email protected]>"
}snake_case, your JSON is camelCase, and display_name is computed on the fly β no extra column, no manual string building in every handler.Try It (Setup + curl)
fastapi==0.116.1
uvicorn[standard]==0.30.6
sqlmodel==0.0.22
pydantic[email]==2.9.2from sqlmodel import Session, SQLModel, create_engine
engine = create_engine(
"sqlite:///app.db",
connect_args={"check_same_thread": False},
)
def create_db_and_tables() -> None:
SQLModel.metadata.create_all(engine)
def get_session():
with Session(engine) as session:
yield sessionfrom contextlib import asynccontextmanager
from typing import Annotated
from fastapi import Depends, FastAPI, HTTPException, status
from sqlmodel import Session, select
from app.database import create_db_and_tables, engine, get_session
from app.models import User
SessionDep = Annotated[Session, Depends(get_session)]
def hash_password(plain: str) -> str:
return f"hashed::{plain}" # DEMO ONLY β use bcrypt/argon2 in real life
@asynccontextmanager
async def lifespan(app: FastAPI):
create_db_and_tables()
with Session(engine) as session:
if not session.exec(select(User)).first():
session.add(
User(
email="[email protected]",
full_name="Ada Lovelace",
hashed_password=hash_password("secret"),
is_superuser=True,
)
)
session.commit()
yield
app = FastAPI(title="FastAPI Response Models", version="0.1.0", lifespan=lifespan)python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
uvicorn app.main:app --reload# safe read β hashed_password & is_superuser are gone
$ curl -s http://127.0.0.1:8000/users/1
{"id":1,"email":"[email protected]","full_name":"Ada Lovelace"}
# create returns UserRead, never the password you sent
$ curl -s -X POST http://127.0.0.1:8000/users \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]","full_name":"Grace Hopper","password":"pw"}'
{"id":2,"email":"[email protected]","full_name":"Grace Hopper"}
# open http://127.0.0.1:8000/docs β every response schema is documented/docs shows callers exactly what each endpoint returns.Common Mistakes (and Fixes)
| Mistake | Symptom | Fix |
|---|---|---|
| Returning the ORM/DB model directly | Sensitive columns leak into the JSON | Declare a response_model allowlist |
Forgetting from_attributes=True | Validation error building the model from an ORM object | Add model_config = ConfigDict(from_attributes=True) |
| One schema for input and output | Password required on read, or echoed on write | Split into XCreate / XRead |
Confusing exclude_unset with exclude_none | Fields you set to None still appear (or vanish) | unset = never set; none = value is None β pick the right one |
| Trimming everywhere with exclude/include | Hard-to-read routes, messy /docs | Define a dedicated schema when shapes differ consistently |
| Heavy logic to shape output by hand | Duplicated serialization code in every handler | Let response_model + computed fields do it |
response_model is your last line of defense against data leaks, but it only helps if you actually set one on every route that returns user or account data. Make it a review-checklist item.Production Notes
- Default to a schema, not the model. Treat "return the DB object directly" as a code smell in review. Every public route gets an explicit
response_model. - Keep schemas in one place. A dedicated
schemas.py(or aschemas/package) makes it obvious what each endpoint exposes β seefastapi-project-structure. - Validate output too.
response_modelvalidation catches the day a query accidentally returns the wrong type before a client does. - Pick an alias policy once. If your frontend is camelCase, apply
to_camelacross all read schemas so the whole API is consistent.
What's Next
python-fast-api-schemaβ the Pydantic request/response basics this post builds onfastapi-crud-patternsβ put Create/Read/Update schemas to work across full CRUDfastapi-error-handlingβ shape your error responses as deliberately as your success onesfastapi-jwtβ real password hashing and auth for thehash_passwordstub above
Recap: never return a DB model raw. A response_model is an allowlist that filters, validates, and documents your output in one move. Split input from output schemas, reach for exclude/exclude_unset/exclude_none for targeted trims, and lean on nested models, computed fields, and aliases to hand the frontend exactly the shape it wants β safely.
fastapi-response-models/ # β
finished
βββ requirements.txt
βββ app/
βββ __init__.py
βββ database.py # engine + get_session
βββ models.py # User table (hashed_password stays server-side)
βββ schemas.py # UserCreate / UserRead / UserUpdate / UserProfile / nested
βββ main.py # every route returns a response_model