FastAPI

FastAPI Response Models (Shape Output & Stop Data Leaks)

Thirdy Gayares
12 min read

🎯 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 / XRead input vs output schema pattern
  • response_model_exclude / include for per-route trimming
  • exclude_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_model

The 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.

app/models.py
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 business
app/main.py (the naive version)
from 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 row

Call 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
}
⚠️
This is a real incident, not a nitpick. You just handed every caller the password hash (crackable offline) and an internal privilege flag. The moment you return a DB model directly, every column it has β€” now and every one you add later β€” is public. That's the bug we're fixing.

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.

app/schemas.py
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_superuser
app/main.py
from 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 it

Same request, safe response:

$ curl -s http://127.0.0.1:8000/users/1
{
  "id": 1,
  "email": "[email protected]",
  "full_name": "Ada Lovelace"
}
πŸ’‘
Ang importante dito: you keep returning the full ORM object. FastAPI does the trimming β€” it builds a 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:

JobWhat it means for you
FilterOnly fields declared on the model are serialized β€” an allowlist, not a blocklist.
Validate & coerceOutput is validated against the schema; wrong types are caught before they reach the client.
DocumentThe exact response shape appears in /docs and the OpenAPI schema, so clients know what to expect.
πŸ’‘
Because it's an allowlist, adding a new column to the 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.
⚠️
Two ways to declare it, one gotcha. The return-type hint (-> 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.

app/schemas.py
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/main.py
@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 UserRead
⚠️
Learning shortcut: hash_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.
βœ…
Why separate schemas win: input validation (a required 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).

app/main.py
# 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"
}
πŸ’‘
Reach for 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:

OptionDrops a field when…Use it for
exclude_unsetthe field was never explicitly set on the model instanceEchoing back only what the client actually sent (PATCH)
exclude_noneits value is NoneHiding null fields to keep payloads small
exclude_defaultsits value still equals the schema defaultSending 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/main.py
@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 user

And on the output side you can drop nulls for a specific route with response_model_exclude_none:

app/main.py
@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 appear
⚠️
Common trap: exclude_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.

app/schemas.py
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 post
app/main.py
from 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 user
πŸ’‘
Filtering is recursive. Even if each Post 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.

app/schemas.py
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}>"
app/main.py
# 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]>"
}
βœ…
Frontend-friendly by design: your Python stays 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)

requirements.txt
fastapi==0.116.1
uvicorn[standard]==0.30.6
sqlmodel==0.0.22
pydantic[email]==2.9.2
1Database + session dependency
app/database.py
from 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 session
2Wire the app + seed one user
app/main.py (top of file)
from 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)
3Run it and compare the leaky vs safe response
run.sh
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
βœ…
You did it: no route leaks the hash, inputs and outputs have distinct shapes, and /docs shows callers exactly what each endpoint returns.

Common Mistakes (and Fixes)

MistakeSymptomFix
Returning the ORM/DB model directlySensitive columns leak into the JSONDeclare a response_model allowlist
Forgetting from_attributes=TrueValidation error building the model from an ORM objectAdd model_config = ConfigDict(from_attributes=True)
One schema for input and outputPassword required on read, or echoed on writeSplit into XCreate / XRead
Confusing exclude_unset with exclude_noneFields you set to None still appear (or vanish)unset = never set; none = value is None β€” pick the right one
Trimming everywhere with exclude/includeHard-to-read routes, messy /docsDefine a dedicated schema when shapes differ consistently
Heavy logic to shape output by handDuplicated serialization code in every handlerLet response_model + computed fields do it
⚠️
Security reminder: a 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 a schemas/ package) makes it obvious what each endpoint exposes β€” see fastapi-project-structure.
  • Validate output too. response_model validation 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_camel across all read schemas so the whole API is consistent.

What's Next

βœ…
πŸš€ Recommended next reads:
  • python-fast-api-schema β€” the Pydantic request/response basics this post builds on
  • fastapi-crud-patterns β€” put Create/Read/Update schemas to work across full CRUD
  • fastapi-error-handling β€” shape your error responses as deliberately as your success ones
  • fastapi-jwt β€” real password hashing and auth for the hash_password stub 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

About the Author

TG

Thirdy Gayares

Passionate developer creating custom solutions for everyone. I specialize in building user-friendly tools that solve real-world problems while maintaining the highest standards of security and privacy.