In this post
What this post covers
What your endpoint returns and what the client receives are not the same thing. Between them
sits the response model: FastAPI validates the return value against it, filters
out every field the model does not declare, and serializes the result to JSON. Get this right and internal
fields can never leak. Get it wrong (return a raw JSONResponse, or skip the model) and a password
hash ends up in a mobile app’s cache. This part also measures the serialization speed-up FastAPI gained in
version 0.130.
A packing station with a manifest
Your function hands a box of whatever it has to the packing station. The station packs only what is on
the manifest (the response model), checks each item, and seals it. Items not on the manifest stay in the
warehouse, however careless the function was. Hand the courier a pre-sealed box instead (a raw
Response) and the station is skipped entirely.
1. The response model filters what leaves
the same object, with and without a response model
from datetime import datetime
from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import BaseModel
class UserRecord(BaseModel): # what the database layer gives you
id: int
email: str
password_hash: str
is_admin: bool
created_at: datetime
class UserOut(BaseModel): # what clients may see
id: int
email: str
created_at: datetime
def load_user(user_id: int) -> UserRecord:
return UserRecord(id=user_id, email="[email protected]", password_hash="$2b$12$Qf…secret",
is_admin=True, created_at=datetime(2026, 9, 19, 10, 30))
app = FastAPI()
@app.get("/leaky/{user_id}")
def leaky(user_id: int): # no model: everything goes out
return load_user(user_id)
@app.get("/users/{user_id}")
def safe(user_id: int) -> UserOut: # the return type IS the response model
return load_user(user_id) # a UserRecord goes in; only UserOut's fields come out
client = TestClient(app)
print("leaky:", sorted(client.get("/leaky/1").json()))
print("safe: ", client.get("/users/1").json())
Output
leaky: ['created_at', 'email', 'id', 'is_admin', 'password_hash']
safe: {'id': 1, 'email': '[email protected]', 'created_at': '2026-09-19T10:30:00'}
Declare a return type or response_model= on every endpoint that returns data. Both do the
same job. If you annotate the function with one type and pass response_model with another,
response_model wins, which is useful when a function returns an ORM object but the response is
a Pydantic model.
2. Returning a Response bypasses all of it
JSONResponse skips validation and filtering
from fastapi import FastAPI
from fastapi.responses import JSONResponse
from fastapi.testclient import TestClient
from pydantic import BaseModel
class Product(BaseModel):
sku: str
price: float
app = FastAPI()
@app.get("/products/a", response_model=Product)
def via_model():
return {"sku": "TEA-01", "price": 120.0, "cost_price": 71.5} # filtered
@app.get("/products/b", response_model=Product)
def via_response():
return JSONResponse({"sku": "TEA-01", "price": "free!", "cost_price": 71.5}) # sent as-is
client = TestClient(app)
print(client.get("/products/a").json())
print(client.get("/products/b").json())
Output
{'sku': 'TEA-01', 'price': 120.0}
{'sku': 'TEA-01', 'price': 'free!', 'cost_price': 71.5}
Return a Response subclass only when you need full control (a file, a redirect, a custom media
type), and then you own validation and filtering yourself. To change status codes, headers or cookies while
keeping the response model, declare a Response parameter instead (section 4).
3. Status codes that mean something
201, 204, 404, and the status module
from fastapi import FastAPI, HTTPException, status
from fastapi.testclient import TestClient
from pydantic import BaseModel
class Note(BaseModel):
text: str
NOTES: dict[int, str] = {}
app = FastAPI()
@app.post("/notes", status_code=status.HTTP_201_CREATED)
def create(note: Note) -> dict:
note_id = len(NOTES) + 1
NOTES[note_id] = note.text
return {"id": note_id}
@app.delete("/notes/{note_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete(note_id: int) -> None:
if NOTES.pop(note_id, None) is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail=f"note {note_id} not found")
client = TestClient(app)
r = client.post("/notes", json={"text": "buy tea"})
print(r.status_code, r.json())
r = client.delete("/notes/1")
print(r.status_code, repr(r.content)) # 204 must have an empty body
print(client.delete("/notes/1").status_code, client.delete("/notes/1").json())
Output
201 {'id': 1}
204 b''
404 {'detail': 'note 1 not found'}
| Code | Use for |
|---|---|
| 200 OK | successful read or update that returns data |
| 201 Created | a POST that created a resource (add a Location header pointing at it) |
| 202 Accepted | work queued for later (part 13) |
| 204 No Content | success with nothing to return (DELETE) |
| 400 / 422 | malformed request / validation failure (FastAPI uses 422 for validation) |
| 401 / 403 | not authenticated / authenticated but not allowed (parts 17–18) |
| 404 / 409 / 412 | not found / conflicts with current state / precondition (ETag) failed (part 16) |
| 429 / 503 | rate limited / temporarily unavailable, both ideally with Retry-After |
4. Headers and cookies without losing the model
a Response parameter: set headers, cookies and status, keep filtering
from fastapi import FastAPI, Response, status
from fastapi.testclient import TestClient
from pydantic import BaseModel
class Order(BaseModel):
id: int
status: str
app = FastAPI()
@app.post("/orders")
def create_order(response: Response) -> Order:
order = {"id": 1042, "status": "pending", "internal_risk_score": 0.93}
response.status_code = status.HTTP_201_CREATED
response.headers["Location"] = f"/orders/{order['id']}"
response.set_cookie("last_order", str(order["id"]), httponly=True, secure=True, samesite="lax",
max_age=3600)
return order # still filtered by Order
client = TestClient(app)
r = client.post("/orders")
print(r.status_code, r.json(), r.headers["location"])
print(r.headers["set-cookie"])
Output
201 {'id': 1042, 'status': 'pending'} /orders/1042
last_order=1042; HttpOnly; Max-Age=3600; Path=/; SameSite=lax; Secure
5. Shaping output: unset fields, None, aliases
response_model_exclude_unset and friends
from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import BaseModel
class Settings(BaseModel):
theme: str = "light"
language: str = "en"
timezone: str | None = None
app = FastAPI()
stored = {"theme": "dark"} # the user only changed the theme
@app.get("/full", response_model=Settings)
def full():
return stored
@app.get("/sparse", response_model=Settings, response_model_exclude_unset=True)
def sparse():
return stored
client = TestClient(app)
print(client.get("/full").json()) # defaults filled in
print(client.get("/sparse").json()) # only what was actually set
Output
{'theme': 'dark', 'language': 'en', 'timezone': None}
{'theme': 'dark'}
6. Serialization speed: declare the type (FastAPI 0.130+)
When an endpoint has a declared return type or response model, FastAPI 0.130+ asks Pydantic to validate and
serialize the response to JSON in one step, inside Pydantic’s Rust core. Without one, it falls back to
jsonable_encoder, a Python-level walk over the data, followed by a separate JSON dump. That is also
why ORJSONResponse was deprecated in 0.131: the fast path no longer needs it. Measured here on a
list of 2,000 orders:
the same data, with and without a declared type
import time
from datetime import datetime, timedelta
from decimal import Decimal
from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import BaseModel
class OrderOut(BaseModel):
id: int
customer: str
total: Decimal
created_at: datetime
tags: list[str]
start = datetime(2026, 9, 1)
ROWS = [OrderOut(id=i, customer=f"customer-{i % 300}", total=Decimal(i % 997) + Decimal("0.99"),
created_at=start + timedelta(minutes=i), tags=["web", "prepaid"]) for i in range(2_000)]
app = FastAPI()
@app.get("/typed")
def typed() -> list[OrderOut]:
return ROWS
@app.get("/untyped")
def untyped():
return ROWS
client = TestClient(app)
assert client.get("/typed").json() == client.get("/untyped").json() # identical output
for path in ("/untyped", "/typed"):
client.get(path) # warm up
t = time.perf_counter()
for _ in range(20):
client.get(path)
print(f"{path:9} {(time.perf_counter() - t) / 20 * 1000:6.1f} ms per request")
print("Decimal on the wire:", repr(client.get("/typed").json()[1]["total"]))
Output
/untyped 41.6 ms per request
/typed 8.8 ms per request
Decimal on the wire: '1.99'
On this machine the typed endpoint was about five times faster, and it validates the data too. The exact
ratio varies by machine and data shape. Note also that Decimal goes out as a JSON string ("1.99") to preserve
precision. Document that for your clients, because many will expect a number.
7. Other response classes
| Class | For |
|---|---|
HTMLResponse, PlainTextResponse | small non-JSON bodies (set response_class= to document the media type) |
RedirectResponse | 307/308 keep the method and body; 303 turns a POST into a GET after a form submit |
FileResponse | files from disk, streamed efficiently with correct headers (part 14) |
StreamingResponse, EventSourceResponse | streams, JSON Lines and Server-Sent Events (part 14) |
ORJSONResponse, UJSONResponse | deprecated since 0.131: declare types instead and get Rust-speed serialization |
Recap
- Declare a response model (return type or
response_model) everywhere: it validates, filters and documents. - Returning a
Responsebypasses the model; use aResponseparameter to set headers, cookies and status instead. - Status codes carry meaning: 201 with Location, 204 empty, 404, 409, 412, 429 with Retry-After.
- Typed responses serialize in Rust (0.130+) and are measurably faster;
Decimaltravels as a string.
Get the free RAG production checklist
25 checks to run before real users see your RAG feature: exact-match misses, invented citations, prompt injection through documents, quality that slips after a prompt change. Confirm your email and the PDF comes with the welcome email, plus each new experiment.