Response Models & Status Codes
In Phase 3 you used Pydantic models to describe what comes in — the request body — and FastAPI validated it for free. This phase is the mirror image: describing what goes out. And the mental model that makes it click is this: the shape of what you send is not the same as the shape of what you return, and pretending they're the same is the single most common way APIs leak data or accept things they shouldn't.
Think of an endpoint as having two contracts. The input contract is what a client is allowed to send you (a new book's title and author — but not its database id, and definitely not your private notes about it). The output contract is what you promise to hand back (the id you assigned, the public fields — but again, not your private notes). Those two contracts are different, so they deserve different models. Once you hold that idea, everything in this phase is just FastAPI giving you a clean way to declare both.
response_model — declaring the shape of what you return
📝 response_model — a parameter you pass to a path operation that tells FastAPI the Pydantic model
your endpoint's return value should conform to. FastAPI then does three things with it: validates that your
return value fits the shape, serializes it to JSON in exactly that shape, and documents it in the
auto-generated /docs page. One declaration, three jobs.
Here's the Book domain from Phase 3, now with an output model declared on the endpoint:
=
:
:
:
# Imagine this came from a database.
return
What just happened: the response_model=BookPublic on the decorator is the whole point. Even though
get_book returns a plain dict, FastAPI runs that dict through BookPublic on the way out — checking
the fields exist and have the right types, then producing JSON shaped exactly like BookPublic. Your
function can return a dict, a Pydantic object, or an ORM row; the response model is what the client
actually sees. And /docs now shows the precise response schema, so the documentation can't drift from
reality.
💡 The return type annotation (def get_book(...) -> BookPublic:) works too and is increasingly the
preferred style. response_model= is shown here because it's explicit, still extremely common in real
code, and has a couple of extra powers (like response_model=None to opt out). Pick one; don't use both on
the same endpoint with conflicting types.
Input vs output models — the key pattern
This is the idea the whole phase is built around, so let's make it concrete. You want two models:
BookCreate— what a client sends to create a book. Noid(the server assigns that), no internal fields.BookPublic— what you return. Has theid, has the public fields, but hides anything internal.
Why bother with two when one "Book" model would compile fine?
⚠️ Two reasons, and both are bugs waiting to happen if you ignore them. First, a single model lets clients
set fields they have no business setting — like the id, or an is_admin flag, or created_by. If your
input model has an id field, a client can pick its own id. Second, returning your internal object leaks
fields — a secret_notes column, a password hash, an internal cost. The response model is your filter:
it strips the output down to exactly the fields it declares, no matter what extra junk the source object
carries.
Let's prove the stripping with a runnable example. Pydantic does the filtering, so we can demonstrate it without a running server — this is exactly what FastAPI does internally with your return value:
# What clients send — notice: no id, no internal fields.
:
:
# What we store internally — has server-controlled and private fields.
:
:
:
: # internal! must never reach the client
: # also internal
# What we return to clients — public fields only.
:
:
:
# Simulate the full round trip.
=
=
# This is what response_model=BookPublic does: filter the internal object
# down to exactly the public model's fields.
=
Stored object has secrets: {'id': 1, 'title': 'Dune', 'author': 'Frank Herbert', 'secret_notes': 'bought cheap at an estate sale', 'acquisition_cost': 2.5}
Public response is clean: {'id': 1, 'title': 'Dune', 'author': 'Frank Herbert'}
What just happened: the internal BookInDB object carries secret_notes and acquisition_cost. When we
fed it through BookPublic, those fields vanished — BookPublic only knows about id, title, and
author, so that's all that survives. In a real FastAPI app you don't write the model_validate line
yourself; declaring response_model=BookPublic makes FastAPI do precisely this filtering on every response.
The client cannot see what the output model doesn't declare.
Here's the same split wired into real endpoints — BookCreate going in, BookPublic coming out:
=
:
:
:
:
:
# The server assigns the id — the client never gets to.
= 1 # pretend the database generated this
return
What just happened: the request body is parsed as BookCreate, which has no id — so there's no way for
a client to smuggle one in; the server is fully in control of that. The response is shaped by BookPublic,
which does include id. The two models making the input and output contracts explicit is the entire
pattern. You'll see it everywhere in well-built FastAPI code.
Status codes — saying what actually happened
An HTTP response isn't just a body; it carries a status code that tells the client what happened in one
number. By default FastAPI returns 200 OK from every successful endpoint, but 200 isn't always the
honest answer. A freshly created resource deserves 201. A successful delete with nothing to return
deserves 204. Using the right code is part of a clean API contract — clients (and other tools) read these
codes to decide what to do next.
The ones you'll reach for constantly:
| Code | Means | Use it when |
|---|---|---|
200 OK |
Success, here's the body | A normal GET or update returning data |
201 Created |
A new resource was created | A POST that creates something |
204 No Content |
Success, deliberately no body | A DELETE that succeeded |
404 Not Found |
The thing you asked for doesn't exist | Looking up a book id that isn't there |
422 Unprocessable Entity |
The request body failed validation | FastAPI returns this for you automatically when Pydantic validation fails |
For the full tour of what each status code family means and why, see HTTP Explained — it covers the 2xx/4xx/5xx logic that this table only summarizes.
You set the success code with status_code on the decorator. A POST that creates a book should say so:
=
:
:
:
:
:
= 1
return
What just happened: status_code=status.HTTP_201_CREATED (which is just the integer 201 with a readable
name) makes a successful create respond with 201 Created instead of the default 200. You could write
status_code=201 directly; the status constants exist so your code reads as intent, not as magic numbers.
The /docs page picks this up too, so the documented success code matches what the endpoint really sends.
📝 The 422 is special: you almost never set it yourself. When a request body fails Pydantic validation —
wrong type, missing required field — FastAPI automatically rejects it with 422 and a detailed JSON
explanation. That's the validation from Phase 3 showing up as an HTTP status.
Raising errors with HTTPException
So far our endpoints assume the happy path. But what about looking up a book that doesn't exist? You don't
return an error — you raise one. FastAPI gives you HTTPException for exactly this: raise it, and
FastAPI catches it and turns it into a clean JSON error response with the status code you chose.
📝 HTTPException — an exception you raise to short-circuit a request with a specific HTTP status and
message. FastAPI converts it into a proper error response; you never build the response by hand.
=
:
:
:
# Pretend this is our database.
=
return
What just happened: when the requested book_id isn't in our books store, we raise HTTPException(...)
with 404 and a detail message. FastAPI stops processing the request right there and sends back a 404
response — it does not try to apply the response_model, because we never returned a value. Raising (not
returning) is the key: it cleanly aborts the endpoint.
If a client requests GET /books/999, the response status is 404 Not Found and the body is:
What just happened: FastAPI wrapped your detail string in a consistent JSON shape — always a detail
key — and set the HTTP status to 404. Every error in your API comes out in this same predictable
envelope, which is exactly what clients want: one place to look for what went wrong.
Why this matters
💡 The input/output-model split is the FastAPI way to keep your API contract clean and safe. Separate
models mean clients can't set server-controlled fields (no rogue ids), and your internal objects can't
leak private fields (no secret_notes in the wild). Pair that with response_model and your /docs page
is always honest — the documented response shape is the real response shape, because FastAPI generates
one from the other. Add honest status codes (201 on create, a clean 404 via HTTPException) and your
API communicates clearly to every client and tool that talks to it.
⚠️ The classic beginner mistake is using one model for everything — a single Book class for input,
output, and storage. It feels simpler on day one and turns into a liability by week two: either you expose
fields you didn't mean to, or you accept fields you shouldn't, or both. Start with the split. As the API
grows you'll often add a third model for the database/internal shape (like BookInDB above) — three
models, three contracts, zero leaks.
Next up: dependency injection with Depends() — how FastAPI lets you pull shared logic (database
sessions, the current user, common parameters) into reusable functions that your endpoints ask for.
Recap
response_modeldeclares the shape an endpoint returns; FastAPI validates the return value against it, serializes to exactly that shape, and documents it in/docs.- Split input from output models —
BookCreate(what clients send, noid) vsBookPublic(what you return, withid). Different contracts deserve different models. - The response model filters output: any field your internal object has but the output model doesn't
declare (like
secret_notes) is stripped before it reaches the client. - Status codes say what happened:
200(default success),201(created),204(no content),404(not found),422(validation failed, set automatically). Set the success code withstatus_code=. HTTPExceptionis how you signal errors —raiseit (don't return) with a status anddetail, and FastAPI sends a clean, consistent JSON error.- ⚠️ One model for everything leaks data and accepts bad input — separate them from the start and your API contract stays safe and honest as it grows.
Quick check
Test yourself on the one idea that anchors this phase — input and output are different contracts:
[
{
"q": "You return an internal object that has a `secret_notes` field, but your endpoint declares `response_model=BookPublic` (which has no `secret_notes`). What does the client receive?",
"choices": [
"The response with `secret_notes` stripped out — response_model filters the output to only its declared fields",
"The full object including `secret_notes`, because you returned it",
"A 500 error, because the object has an extra field",
"A 422 error, because the output failed validation"
],
"answer": 0,
"explain": "response_model is a filter. FastAPI runs your return value through BookPublic, which only declares id/title/author, so secret_notes never reaches the client. This is exactly why you split output models from internal ones."
},
{
"q": "Why use a separate `BookCreate` (no `id`) for input instead of one shared `Book` model that includes `id`?",
"choices": [
"So clients can't set the `id` themselves — the server controls server-assigned fields",
"Because Pydantic can't validate a model that has an `id` field",
"Because FastAPI requires every endpoint to use a different model",
"Because it makes the JSON response smaller"
],
"answer": 0,
"explain": "If the input model has an `id`, a client can choose its own id. Keeping `id` out of BookCreate means the server is fully in control of it. That's the safety half of the input/output split."
},
{
"q": "A client requests a book id that doesn't exist. What's the right way to respond with a 404?",
"choices": [
"`raise HTTPException(status_code=404, detail=\"Book not found\")`",
"`return {\"error\": 404, \"message\": \"Book not found\"}`",
"`return None` and let FastAPI figure out it's missing",
"Set `status_code=404` on the decorator so every response is a 404"
],
"answer": 0,
"explain": "You raise HTTPException, not return an error dict. Raising short-circuits the endpoint and FastAPI converts it into a clean JSON error with the right status. Returning a dict would send a 200 with an error-shaped body, and decorator status_code would wrongly apply to all responses."
}
]
← Phase 3: Pydantic Models & Validation · Guide overview · Phase 5: Dependency Injection with Depends() →
Check your understanding
1. You return an internal object that has a `secret_notes` field, but your endpoint declares `response_model=BookPublic` (which has no `secret_notes`). What does the client receive?
2. Why use a separate `BookCreate` (no `id`) for input instead of one shared `Book` model that includes `id`?
3. A client requests a book id that doesn't exist. What's the right way to respond with a 404?