Response Headers, Cookies and Custom Responses

Most FastAPI route handlers return Python dicts, Pydantic models, or ORM objects, and FastAPI serialises them to JSON automatically. But sometimes you need more control: setting custom response headers (CORS, cache-control, custom metadata), setting or deleting cookies, returning non-JSON content (files, binary data, CSV), or sending a specific HTTP status code that cannot be determined at route-decoration time. FastAPI provides several mechanisms for this โ€” injecting the Response object, using JSONResponse and other response classes, and the special FileResponse and StreamingResponse classes for file delivery.

Setting Response Headers and Cookies

from fastapi import FastAPI, Response
from fastapi.responses import JSONResponse

app = FastAPI()

# โ”€โ”€ Inject Response to set headers/cookies without changing return type โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
@app.get("/posts/{post_id}")
def get_post(post_id: int, response: Response):
    post = db.query(Post).get(post_id)

    # Set custom response headers
    response.headers["X-Post-Id"]     = str(post_id)
    response.headers["Cache-Control"] = "public, max-age=3600"
    response.headers["X-Response-Time"] = "12ms"

    # Set a cookie
    response.set_cookie(
        key      = "last_viewed_post",
        value    = str(post_id),
        max_age  = 86400,      # 1 day in seconds
        httponly = True,       # not accessible via JavaScript
        samesite = "lax",
    )

    return post   # return value still serialised normally

# โ”€โ”€ Delete a cookie โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
@app.post("/logout")
def logout(response: Response):
    response.delete_cookie("session_id")
    response.delete_cookie("last_viewed_post")
    return {"message": "Logged out"}
Note: Injecting Response as a parameter lets you modify headers and cookies while still returning your data normally โ€” FastAPI handles the serialisation. This is different from returning a Response object, which gives you full control but bypasses FastAPI’s serialisation and response_model filtering. Use injected Response for header/cookie side-effects; return a Response subclass only when you need a completely custom response (file download, non-JSON body, dynamic status code).
Tip: For API endpoints, set Cache-Control headers to control browser and CDN caching behaviour. For read-only public endpoints (GET /posts), Cache-Control: public, max-age=60 allows CDNs to cache for 60 seconds. For user-specific data (GET /profile), use Cache-Control: private, no-store to prevent caching. Setting these headers at the FastAPI layer (rather than the infrastructure layer) keeps the caching logic close to the endpoint that knows the appropriate freshness requirements.
Warning: When you return a JSONResponse, FileResponse, or other Response subclass directly from a route handler, FastAPI bypasses the response_model serialisation and filtering. This means sensitive fields that would normally be stripped by response_model could be included if you manually construct the response dict. Always use response_model with the normal return-value approach for data endpoints; only use explicit Response classes for file delivery, streaming, or truly custom responses.

Response Classes

from fastapi import FastAPI
from fastapi.responses import (
    JSONResponse,
    HTMLResponse,
    PlainTextResponse,
    RedirectResponse,
    FileResponse,
    StreamingResponse,
)

app = FastAPI()

# โ”€โ”€ Dynamic status code โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
@app.get("/posts/{post_id}/or-latest")
def get_post_or_latest(post_id: int):
    post = db.query(Post).get(post_id)
    if post:
        return JSONResponse(status_code=200, content={"id": post.id, "title": post.title})
    # Redirect to latest post if specific one not found
    return RedirectResponse(url="/posts/latest", status_code=307)

# โ”€โ”€ HTML response โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
@app.get("/health", response_class=HTMLResponse)
def health_html():
    return "<html><body><h1>OK</h1></body></html>"

# โ”€โ”€ File download โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
@app.get("/exports/posts.csv")
def export_posts_csv():
    # FileResponse reads a file from disk and sends it
    return FileResponse(
        path     = "/tmp/posts_export.csv",
        filename = "posts.csv",          # Content-Disposition header
        media_type = "text/csv",
    )

# โ”€โ”€ Streaming response (large files or generated content) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
@app.get("/exports/large-posts.csv")
def export_large():
    def generate():
        yield "id,title,view_count\n"
        for post in db.query(Post).yield_per(100):
            yield f"{post.id},{post.title},{post.view_count}\n"

    return StreamingResponse(
        generate(),
        media_type = "text/csv",
        headers    = {"Content-Disposition": 'attachment; filename="posts.csv"'},
    )

Setting Status Code Dynamically

from fastapi import FastAPI, Response, status

app = FastAPI()

# Status code from decorator is the default โ€” override with Response injection
@app.post("/posts", status_code=201)
def create_or_update_post(post: PostCreate, response: Response):
    existing = db.query(Post).filter(Post.slug == post.slug).first()
    if existing:
        # Update existing โ€” return 200 instead of 201
        update_post(existing, post)
        response.status_code = status.HTTP_200_OK
        return existing
    # Create new โ€” use the 201 default from decorator
    return create_post(post)

Common Mistakes

Mistake 1 โ€” Returning JSONResponse and expecting response_model to filter it

โŒ Wrong โ€” response_model is bypassed:

@app.get("/users/{id}", response_model=UserResponse)
def get_user(id: int):
    user = db.query(User).get(id)
    return JSONResponse({"id": user.id, "password_hash": user.password_hash})
    # response_model=UserResponse is IGNORED โ€” password_hash included!

โœ… Correct โ€” return the object and let FastAPI apply response_model:

@app.get("/users/{id}", response_model=UserResponse)
def get_user(id: int):
    return db.query(User).get(id)   # โœ“ response_model filters the output

Mistake 2 โ€” Setting httponly=False on session cookies

โŒ Wrong โ€” JavaScript can read the session cookie (XSS risk):

response.set_cookie("session_id", token)   # httponly defaults to False!

โœ… Correct โ€” always httponly=True for session/auth cookies:

response.set_cookie("session_id", token, httponly=True, secure=True, samesite="lax")   # โœ“

Mistake 3 โ€” Forgetting Content-Disposition for file downloads

โŒ Wrong โ€” browser displays the file instead of downloading it:

return Response(content=csv_data, media_type="text/csv")   # opens in browser!

โœ… Correct โ€” set Content-Disposition:

return Response(content=csv_data, media_type="text/csv",
    headers={"Content-Disposition": 'attachment; filename="export.csv"'})   # โœ“

Quick Reference

Task Code
Set response header response.headers["X-Key"] = "value"
Set cookie response.set_cookie(key, value, httponly=True)
Delete cookie response.delete_cookie("name")
Override status code response.status_code = 200
JSON response return JSONResponse(status_code=201, content={...})
Redirect return RedirectResponse(url="/new", status_code=307)
File download return FileResponse(path, filename="f.csv")
Streaming return StreamingResponse(generator(), media_type=...)

🧠 Test Yourself

You want to add a Cache-Control: public, max-age=300 header to a GET endpoint response while still returning normal JSON data filtered by response_model. What is the correct approach?