CodingNic

REST APIs with FastAPI

Response models

REST APIs with FastAPI 25 min read

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:

  1. 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.
  2. Internal fields can leak. If your database row has an internal_admin_flag or password_hash field, returning that row directly leaks it to the client. Bugs like this have cost real companies real money.
  3. 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:

python
@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 UserOut schema.
  • Anything in the returned value that isn’t in UserOut is 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:

text
fastapi-responses/
└── main.py

Install the email dependency from last lesson if you haven’t:

bash
pip install fastapi uvicorn 'pydantic[email]'

Open main.py and start with two models — one for input, one for output:

python
# 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:

python
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:

python
@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

bash
uvicorn main:app --reload

Create a user:

bash
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:

json
{
  "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:

bash
curl http://127.0.0.1:8000/users
json
[
  {
    "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:

python
# 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:

bash
curl http://127.0.0.1:8000/users/99
json
{
  "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:

python
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:

python
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:

python
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:

bash
curl -X POST http://127.0.0.1:8000/tasks \
  -H "Content-Type: application/json" \
  -d '{"title": "Buy groceries", "priority": 1}'
json
{
  "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 for PATCH endpoints, where you only want to update the fields the client actually sent.
  • Datetimes are kept as datetime objects. They get converted to ISO strings during the FastAPI → JSON step, not by model_dump() itself. (If you call model_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 (with password required).
  • The success response (201) is documented as UserOut (with id and created_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:

python
@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_model and accidentally returning a SQLAlchemy row. Without response_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 id in the input model. The server generates IDs. Don’t accept them from the client — that lets clients overwrite each other’s records. UserIn has no id; UserOut does.
  • 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 UserOut declares created_at: datetime but your dict doesn’t include that key, FastAPI raises a ResponseValidationError. 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, use response_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 XxxIn for input and XxxOut for 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.