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?

SituationExplicit JSONResponse?
Returning simple JSON with no custom headersNo — just return the dict
You need custom headersYes
You need a status code other than 200Yes (or use HTTPException)
You want to set cookiesYes
You need total control of the content-typeYes

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

ParameterWhat it doesRecommendation
keyThe cookie's nameDescriptive: session_token, csrf_token
valueThe cookie's valueA secure token — never sensitive data in plain text
max_ageSeconds until expiration3600 (1 hour) for sessions
httponlyNot reachable from JavaScriptTrue for session tokens
secureOnly sent over HTTPSTrue in production
samesiteCSRF 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

CodeMeaningWhen to use it
307Temporary Redirect (default)A temporary redirect — the client can go back to the original URL
301Moved PermanentlyThe URL changed permanently — SEO and caches update
302FoundA temporary redirect (legacy — use 307 instead)
303See OtherAfter 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

  • JSONResponse gives you total control over status code, headers and content — use it when you need custom headers
  • The injected Response object lets you set headers without losing response_model validation
  • Cookies: response.set_cookie() with httponly=True, secure=True in production, samesite="lax"
  • RedirectResponse with status 301 (permanent), 307 (temporary), 303 (post→get)
  • HTMLResponse to return HTML directly — use response_class=HTMLResponse in the decorator
  • ORJSONResponse for high-performance JSON serialization — pip install orjson
  • The responses parameter 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_model combine: Pydantic validation + a custom serializer
  • Custom X- headers communicate metadata without polluting the JSON body

Additional resources

  1. FastAPI - Custom Response — Every available response type: JSONResponse, HTMLResponse, etc.
  2. FastAPI - Additional Responses — Documenting multiple responses with the responses parameter
  3. FastAPI - Response Cookies — How to set and delete cookies
  4. FastAPI - Response Headers — Custom headers on responses
  5. orjson Documentation — A high-performance JSON serialization library
  6. MDN - HTTP Response Headers — The complete HTTP headers reference
  7. 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.