Text size
Dark mode
RecallRunGuides & benchmarks
Learn to build real systemsGuides•Benchmarks•Experiments•Community
Search the site/Free checklist

Guides/FastAPI

FastAPI: Responses & serialization

What your endpoint returns and what the client receives are not the same thing.

In this post

What this post covers
  1. 1. The response model filters what leaves
  2. 2. Returning a Response bypasses all of it
  3. 3. Status codes that mean something
  4. 4. Headers and cookies without losing the model
  5. 5. Shaping output: unset fields, None, aliases
  6. 6. Serialization speed: declare the type (FastAPI 0.130+)
  7. 7. Other response classes
  8. Recap

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.

your return valuedict · ORM object · model validate + filteragainst the return type;extra fields dropped serialize to JSONPydantic core (Rust) Response → bytesstatus · headers · media type return JSONResponse(...) yourself: no validation, no filtering, no response_model

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'}
Filtering needs a declared model

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'}
CodeUse for
200 OKsuccessful read or update that returns data
201 Createda POST that created a resource (add a Location header pointing at it)
202 Acceptedwork queued for later (part 13)
204 No Contentsuccess with nothing to return (DELETE)
400 / 422malformed request / validation failure (FastAPI uses 422 for validation)
401 / 403not authenticated / authenticated but not allowed (parts 17–18)
404 / 409 / 412not found / conflicts with current state / precondition (ETag) failed (part 16)
429 / 503rate 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

ClassFor
HTMLResponse, PlainTextResponsesmall non-JSON bodies (set response_class= to document the media type)
RedirectResponse307/308 keep the method and body; 303 turns a POST into a GET after a form submit
FileResponsefiles from disk, streamed efficiently with correct headers (part 14)
StreamingResponse, EventSourceResponsestreams, JSON Lines and Server-Sent Events (part 14)
ORJSONResponse, UJSONResponsedeprecated 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 Response bypasses the model; use a Response parameter 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; Decimal travels 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.

One email to confirm. Unsubscribe any time. What's in it · Privacy

Want the production version of this? See ShipRAG: A production RAG + MCP starter kit for FastAPI: hybrid search, validated citations, evals in CI, Docker, 32 tests.

Written by

Alby Tomy

Python/FastAPI Engineer | Data Engineer | AI Engineer building RAG and MCP systems.

X · GitHub · Contact & enquiries