Module 3: Advanced Response Models
Custom Responses and Headers
Capsule overview
In the previous capsules you learned to control what data goes out (response_model) and how to send large data (StreamingResponse, FileResponse). But there are situations where you need total control over the HTTP response: setting custom headers, sending cookies, redirecting to another URL, returning HTML directly, or using a JSON serializer that's faster than the default.
In this capsule you'll explore the response types FastAPI offers beyond automatic JSON: JSONResponse for custom headers and status codes, RedirectResponse for redirects, HTMLResponse for serving HTML, and ORJSONResponse for high-performance serialization. You'll learn to set cookies from responses and — crucially — to document multiple responses in OpenAPI using the responses parameter in your endpoint decorators.
JSONResponse: total control over the response
When you return a dict from an endpoint, FastAPI automatically wraps it in a JSONResponse. But sometimes you need explicit control:
from fastapi import FastAPI
from fastapi.responses import JSONResponse
app = FastAPI()
@app.get("/tasks/{task_id}")
def get_task(task_id: int):
tasks = {1: {"id": 1, "title": "Setup CI/CD", "status": "in_progress"}}
if task_id not in tasks:
return JSONResponse(
status_code=404,
content={"error": "Task not found", "task_id": task_id},
headers={"X-Error-Code": "TASK_NOT_FOUND"},
)
return JSONResponse(
content=tasks[task_id],
headers={
"X-Task-Status": tasks[task_id]["status"],
"Cache-Control": "max-age=60",
},
)
curl -v http://127.0.0.1:8000/tasks/1 2>&1 | grep -i "x-task"
# < x-task-status: in_progress
curl -v http://127.0.0.1:8000/tasks/999 2>&1 | grep -i "x-error"
# < x-error-code: TASK_NOT_FOUND
When should you use an explicit JSONResponse?
| Situation | Explicit JSONResponse? |
|---|---|
| Returning simple JSON with no custom headers | No — just return the dict |
| You need custom headers | Yes |
| You need a status code other than 200 | Yes (or use HTTPException) |
| You want to set cookies | Yes |
| You need total control of the content-type | Yes |
Custom headers with the Response object
Another way to set headers is by injecting the Response object:
from fastapi import FastAPI, Response
from pydantic import BaseModel
from datetime import datetime
app = FastAPI()
class Task(BaseModel):
id: int
title: str
status: str
tasks_db = [
Task(id=1, title="Setup CI/CD", status="in_progress"),
Task(id=2, title="Write tests", status="pending"),
Task(id=3, title="Deploy", status="completed"),
]
@app.get("/tasks", response_model=list[Task])
def list_tasks(response: Response):
response.headers["X-Total-Count"] = str(len(tasks_db))
response.headers["X-Generated-At"] = datetime.now().isoformat()
return tasks_db
The advantage of this approach: you keep response_model (for validation and OpenAPI documentation) and add custom headers on top. With an explicit JSONResponse, you lose response_model's automatic validation.
Cookies from responses
Cookies are set as special headers. FastAPI gives you two ways:
With the Response object
from fastapi import FastAPI, Response
app = FastAPI()
@app.post("/auth/login")
def login(response: Response):
response.set_cookie(
key="session_token",
value="abc123xyz",
max_age=3600,
httponly=True,
samesite="lax",
)
return {"message": "Login successful"}
@app.post("/auth/logout")
def logout(response: Response):
response.delete_cookie(key="session_token")
return {"message": "Logged out"}
With JSONResponse
from fastapi import FastAPI
from fastapi.responses import JSONResponse
app = FastAPI()
@app.post("/auth/login")
def login():
response = JSONResponse(content={"message": "Login successful"})
response.set_cookie(
key="session_token",
value="abc123xyz",
max_age=3600,
httponly=True,
samesite="lax",
)
return response
Cookie parameters
| Parameter | What it does | Recommendation |
|---|---|---|
key | The cookie's name | Descriptive: session_token, csrf_token |
value | The cookie's value | A secure token — never sensitive data in plain text |
max_age | Seconds until expiration | 3600 (1 hour) for sessions |
httponly | Not reachable from JavaScript | True for session tokens |
secure | Only sent over HTTPS | True in production |
samesite | CSRF protection | "lax" or "strict" |
RedirectResponse
To redirect the client to another URL:
from fastapi import FastAPI
from fastapi.responses import RedirectResponse
app = FastAPI()
@app.get("/")
def root():
return RedirectResponse(url="/docs")
@app.get("/old-tasks")
def old_tasks_redirect():
return RedirectResponse(
url="/api/v2/tasks",
status_code=301,
)
@app.get("/external")
def external_redirect():
return RedirectResponse(url="https://fastapi.tiangolo.com")
Redirect status codes
| Code | Meaning | When to use it |
|---|---|---|
| 307 | Temporary Redirect (default) | A temporary redirect — the client can go back to the original URL |
| 301 | Moved Permanently | The URL changed permanently — SEO and caches update |
| 302 | Found | A temporary redirect (legacy — use 307 instead) |
| 303 | See Other | After a POST, redirects to a GET |
HTMLResponse
To return HTML directly (handy for simple pages, formatted health checks, or landing pages):
from fastapi import FastAPI
from fastapi.responses import HTMLResponse
app = FastAPI()
@app.get("/status", response_class=HTMLResponse)
def status_page():
return """
<!DOCTYPE html>
<html>
<head><title>API Status</title></head>
<body>
<h1>Task Manager API</h1>
<p>Status: <strong style="color: green;">Online</strong></p>
<p>Version: 3.0.0</p>
<ul>
<li><a href="/docs">API Documentation</a></li>
<li><a href="/tasks">Tasks Endpoint</a></li>
</ul>
</body>
</html>
"""
Notice the response_class=HTMLResponse in the decorator. That tells FastAPI the content-type is text/html, not application/json. The documentation in /docs will reflect it.
ORJSONResponse: performance with orjson
orjson is a JSON serialization library written in Rust. It's significantly faster than the standard library's json. FastAPI supports ORJSONResponse as a drop-in replacement:
pip install orjson
from fastapi import FastAPI
from fastapi.responses import ORJSONResponse
app = FastAPI(default_response_class=ORJSONResponse)
@app.get("/tasks")
def list_tasks():
return [
{"id": 1, "title": "Setup CI/CD", "status": "in_progress"},
{"id": 2, "title": "Write tests", "status": "pending"},
]
With default_response_class=ORJSONResponse, every endpoint uses orjson to serialize. You can also apply it to individual endpoints:
app = FastAPI()
@app.get("/tasks/fast", response_class=ORJSONResponse)
def list_tasks_fast():
return [{"id": i, "title": f"Task {i}"} for i in range(1000)]
When should you use ORJSONResponse?
- APIs with large payloads (thousands of objects per response)
- High-throughput endpoints where serialization is the bottleneck
- Data with lots of numbers, dates, UUIDs (orjson serializes them natively)
For most APIs, the difference is negligible. But if you serialize thousands of objects per request, orjson can be 2-10x faster.
Documenting responses in OpenAPI with responses
The responses parameter in endpoint decorators lets you document every possible response, not just the main one:
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
app = FastAPI()
class Task(BaseModel):
id: int
title: str
status: str
class ErrorDetail(BaseModel):
detail: str
error_code: str
@app.get(
"/tasks/{task_id}",
response_model=Task,
responses={
200: {
"description": "Task found",
"content": {
"application/json": {
"example": {"id": 1, "title": "Setup CI/CD", "status": "in_progress"},
},
},
},
404: {
"description": "Task not found",
"model": ErrorDetail,
"content": {
"application/json": {
"example": {"detail": "Task not found", "error_code": "TASK_NOT_FOUND"},
},
},
},
422: {
"description": "Invalid ID",
},
},
)
def get_task(task_id: int):
tasks = {1: Task(id=1, title="Setup CI/CD", status="in_progress")}
if task_id not in tasks:
raise HTTPException(status_code=404, detail="Task not found")
return tasks[task_id]
Open /docs and you'll see all three possible responses documented with their schemas and examples. That makes the documentation complete: the consumer knows exactly what they can get for each status code.
The simplified form with model
If you only need to document the schema without a custom example:
@app.get(
"/tasks/{task_id}",
response_model=Task,
responses={
404: {"model": ErrorDetail, "description": "Task not found"},
},
)
def get_task(task_id: int):
...
Documenting multiple media types
An endpoint can document that it returns JSON or CSV:
@app.get(
"/tasks/export",
responses={
200: {
"description": "Exported tasks",
"content": {
"application/json": {
"example": [{"id": 1, "title": "Setup CI/CD"}],
},
"text/csv": {
"example": "id,title\n1,Setup CI/CD\n",
},
},
},
},
)
def export_tasks(format: str = "json"):
...
Custom Response Class
You can create your own response class to encapsulate repetitive logic:
from fastapi import FastAPI
from fastapi.responses import JSONResponse
from datetime import datetime
class APIResponse(JSONResponse):
"""A custom response that adds standard headers."""
def __init__(self, content=None, status_code=200, headers=None, **kwargs):
custom_headers = {
"X-API-Version": "3.0.0",
"X-Response-Time": datetime.now().isoformat(),
}
if headers:
custom_headers.update(headers)
wrapped_content = {
"status": "success" if status_code < 400 else "error",
"data": content,
}
super().__init__(
content=wrapped_content,
status_code=status_code,
headers=custom_headers,
**kwargs,
)
app = FastAPI()
@app.get("/tasks")
def list_tasks():
tasks = [
{"id": 1, "title": "Setup CI/CD"},
{"id": 2, "title": "Write tests"},
]
return APIResponse(content=tasks)
@app.get("/tasks/{task_id}")
def get_task(task_id: int):
if task_id != 1:
return APIResponse(
content={"detail": "Task not found"},
status_code=404,
)
return APIResponse(content={"id": 1, "title": "Setup CI/CD"})
curl -v http://127.0.0.1:8000/tasks 2>&1
# Headers: X-API-Version: 3.0.0, X-Response-Time: 2026-03-13T...
# Body: {"status": "success", "data": [{"id": 1, "title": "Setup CI/CD"}, ...]}
Combining response_class with response_model
You can use response_class to change the serializer and response_model for validation:
from fastapi import FastAPI
from fastapi.responses import ORJSONResponse
from pydantic import BaseModel
class Task(BaseModel):
id: int
title: str
status: str
app = FastAPI()
@app.get(
"/tasks",
response_model=list[Task],
response_class=ORJSONResponse,
)
def list_tasks():
return [
{"id": 1, "title": "Setup CI/CD", "status": "in_progress"},
{"id": 2, "title": "Write tests", "status": "pending"},
]
FastAPI validates with response_model (filters fields, checks types) and serializes with ORJSONResponse (performance).
Connection with the project
In the project capsule (05), you'll use JSONResponse with custom headers for pagination metadata, document multiple responses with responses on every endpoint, and create a reusable response wrapper. The schemas from capsule 02, the streaming from 03, and the headers/documentation from this capsule form the complete toolkit for professional responses.
Troubleshooting
Problem 1: response_model doesn't validate when you use an explicit JSONResponse
Cause: When you return a JSONResponse directly, FastAPI doesn't apply response_model. Validation only happens when you return a dict or a Pydantic model.
Fix: If you need validation + custom headers, use the injected Response object:
# ❌ response_model gets ignored with an explicit JSONResponse
@app.get("/tasks", response_model=list[Task])
def list_tasks():
return JSONResponse(content=[...], headers={"X-Custom": "value"})
# ✅ Use the injected Response to keep response_model
@app.get("/tasks", response_model=list[Task])
def list_tasks(response: Response):
response.headers["X-Custom"] = "value"
return tasks_db # FastAPI applies response_model
Problem 2: The cookies don't get sent — the browser ignores them
Cause: CORS problems or wrong cookie flags.
Fix:
# Check these flags:
response.set_cookie(
key="token",
value="abc123",
httponly=True,
secure=True, # True in production (HTTPS)
samesite="lax", # "none" requires secure=True
domain=None, # None = the current domain
path="/", # "/" = the whole app
)
In local development (HTTP, not HTTPS), use secure=False. In production, always secure=True.
Problem 3: ORJSONResponse fails with "orjson is not installed"
Cause: The orjson library isn't installed.
Fix:
pip install orjson
If you can't install orjson (in a restricted environment, for example), use UJSONResponse as an alternative:
pip install ujson
from fastapi.responses import UJSONResponse
Problem 4: The responses parameter doesn't show up in /docs
Cause: A syntax error in the structure of the responses dict.
Fix: Check the structure — each status code is a numeric key holding a dict with description, optionally model, and optionally content:
# ❌ Wrong structure
responses={"404": {"detail": "Not found"}}
# ✅ Correct structure
responses={404: {"description": "Not found", "model": ErrorModel}}
Problem 5: RedirectResponse causes an infinite loop
Cause: The endpoint redirects to itself.
Fix: Check that the destination URL is different from the endpoint's path:
# ❌ Infinite loop — /tasks redirects to /tasks
@app.get("/tasks")
def tasks():
return RedirectResponse(url="/tasks")
# ✅ Redirect to another URL
@app.get("/tasks-old")
def tasks_old():
return RedirectResponse(url="/api/v2/tasks", status_code=301)
Exercises
Exercise 1: Custom headers on a listing (Easy)
Create a GET /products endpoint that returns a list of products and sets the X-Total-Count, X-Page and X-Per-Page headers using the injected Response object. Accept page (default 1) and per_page (default 10) query parameters.
See solution
from fastapi import FastAPI, Response, Query
from pydantic import BaseModel
app = FastAPI()
class Product(BaseModel):
id: int
name: str
price: float
products_db = [Product(id=i, name=f"Product {i}", price=i * 10.0) for i in range(1, 26)]
@app.get("/products", response_model=list[Product])
def list_products(
response: Response,
page: int = Query(default=1, ge=1),
per_page: int = Query(default=10, ge=1, le=50),
):
start = (page - 1) * per_page
end = start + per_page
paginated = products_db[start:end]
response.headers["X-Total-Count"] = str(len(products_db))
response.headers["X-Page"] = str(page)
response.headers["X-Per-Page"] = str(per_page)
return paginated
curl -v "http://127.0.0.1:8000/products?page=2&per_page=5" 2>&1 | grep "x-"
# x-total-count: 25
# x-page: 2
# x-per-page: 5
Exercise 2: Login with a cookie (Easy)
Create POST /login (which takes username and password in the body) and POST /logout endpoints. Login sets a session_id cookie with a simulated value and httponly=True, max_age=1800 (30 min). Logout deletes it.
See solution
from fastapi import FastAPI, Response
from pydantic import BaseModel
import uuid
app = FastAPI()
class LoginRequest(BaseModel):
username: str
password: str
@app.post("/login")
def login(credentials: LoginRequest, response: Response):
if credentials.username == "admin" and credentials.password == "secret":
session_id = str(uuid.uuid4())
response.set_cookie(
key="session_id",
value=session_id,
max_age=1800,
httponly=True,
samesite="lax",
)
return {"message": "Login successful", "session_id": session_id}
return JSONResponse(
status_code=401,
content={"message": "Invalid credentials"},
)
@app.post("/logout")
def logout(response: Response):
response.delete_cookie(key="session_id")
return {"message": "Logged out"}
curl -X POST http://127.0.0.1:8000/login \
-H "Content-Type: application/json" \
-d '{"username": "admin", "password": "secret"}' -v 2>&1 | grep "set-cookie"
# set-cookie: session_id=abc-123-...; HttpOnly; Max-Age=1800; ...
Exercise 3: Documenting multiple responses (Medium)
Create a GET /orders/{order_id} endpoint that returns an Order (200), an error (404), or a validation error (422). Use the responses parameter to document all three responses in OpenAPI with descriptions, models and examples.
See solution
from fastapi import FastAPI, HTTPException, Path
from pydantic import BaseModel
app = FastAPI()
class Order(BaseModel):
id: int
product: str
total: float
status: str
class ErrorResponse(BaseModel):
detail: str
error_code: str
orders_db = {
1: Order(id=1, product="Laptop Pro", total=1299.99, status="shipped"),
2: Order(id=2, product="Monitor 4K", total=499.99, status="pending"),
}
@app.get(
"/orders/{order_id}",
response_model=Order,
responses={
200: {
"description": "Order found successfully",
"content": {
"application/json": {
"example": {"id": 1, "product": "Laptop Pro", "total": 1299.99, "status": "shipped"},
},
},
},
404: {
"description": "Order not found",
"model": ErrorResponse,
"content": {
"application/json": {
"example": {"detail": "Order not found", "error_code": "ORDER_NOT_FOUND"},
},
},
},
422: {
"description": "Invalid order ID (must be a positive integer)",
},
},
)
def get_order(order_id: int = Path(ge=1)):
if order_id not in orders_db:
raise HTTPException(status_code=404, detail="Order not found")
return orders_db[order_id]
Open /docs and expand GET /orders/{order_id} — you'll see all three responses documented with schemas and examples.
Exercise 4: A custom response class (Medium)
Create a TimedResponse that extends JSONResponse and automatically adds an X-Process-Time-Ms header with the current timestamp. Use it in a GET /tasks endpoint.
See solution
from fastapi import FastAPI
from fastapi.responses import JSONResponse
import time
app = FastAPI()
class TimedResponse(JSONResponse):
def __init__(self, content=None, status_code=200, headers=None, **kwargs):
extra_headers = {"X-Process-Time-Ms": str(int(time.time() * 1000))}
if headers:
extra_headers.update(headers)
super().__init__(content=content, status_code=status_code, headers=extra_headers, **kwargs)
tasks = [
{"id": 1, "title": "Setup CI/CD", "status": "in_progress"},
{"id": 2, "title": "Write tests", "status": "pending"},
]
@app.get("/tasks")
def list_tasks():
return TimedResponse(content=tasks)
@app.get("/tasks/{task_id}")
def get_task(task_id: int):
task = next((t for t in tasks if t["id"] == task_id), None)
if task is None:
return TimedResponse(
content={"detail": "Task not found"},
status_code=404,
)
return TimedResponse(content=task)
curl -v http://127.0.0.1:8000/tasks 2>&1 | grep "x-process"
# x-process-time-ms: 1710345678901
Exercise 5: Redirect with versioning (Hard)
Create an API versioning system where GET /v1/tasks redirects to GET /v2/tasks with a 301 (Moved Permanently) and an X-Deprecated-Version: v1 header. The GET /v2/tasks endpoint returns the tasks normally. On top of that, GET /api/latest/tasks redirects to the latest version (v2) with a 307 (Temporary).
See solution
from fastapi import FastAPI
from fastapi.responses import RedirectResponse, JSONResponse
app = FastAPI()
tasks = [
{"id": 1, "title": "Setup CI/CD", "status": "in_progress"},
{"id": 2, "title": "Write tests", "status": "pending"},
]
@app.get("/v1/tasks")
def v1_tasks():
response = RedirectResponse(url="/v2/tasks", status_code=301)
response.headers["X-Deprecated-Version"] = "v1"
response.headers["X-Upgrade-To"] = "v2"
return response
@app.get("/v2/tasks")
def v2_tasks():
return JSONResponse(
content=tasks,
headers={"X-API-Version": "v2"},
)
@app.get("/api/latest/tasks")
def latest_tasks():
return RedirectResponse(url="/v2/tasks", status_code=307)
curl -L -v http://127.0.0.1:8000/v1/tasks 2>&1
# Redirect 301 → /v2/tasks
# Header: X-Deprecated-Version: v1
curl -L http://127.0.0.1:8000/api/latest/tasks
# Redirect 307 → /v2/tasks → [tasks data]
Summary
JSONResponsegives you total control over status code, headers and content — use it when you need custom headers- The injected
Responseobject lets you set headers without losingresponse_modelvalidation - Cookies:
response.set_cookie()withhttponly=True,secure=Truein production,samesite="lax" RedirectResponsewith status 301 (permanent), 307 (temporary), 303 (post→get)HTMLResponseto return HTML directly — useresponse_class=HTMLResponsein the decoratorORJSONResponsefor high-performance JSON serialization —pip install orjson- The
responsesparameter in decorators documents every possible response in OpenAPI with schemas and examples - Custom response classes encapsulate repetitive logic (standard headers, content wrappers)
response_class+response_modelcombine: Pydantic validation + a custom serializer- Custom
X-headers communicate metadata without polluting the JSON body
Additional resources
- FastAPI - Custom Response — Every available response type: JSONResponse, HTMLResponse, etc.
- FastAPI - Additional Responses — Documenting multiple responses with the
responsesparameter - FastAPI - Response Cookies — How to set and delete cookies
- FastAPI - Response Headers — Custom headers on responses
- orjson Documentation — A high-performance JSON serialization library
- MDN - HTTP Response Headers — The complete HTTP headers reference
- MDN - HTTP Cookies — The complete guide to HTTP cookies
Next capsule: Project: Professional Responses — You'll bring differentiated schemas, CSV export, custom headers and OpenAPI documentation together in your Task Manager API.