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"}
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).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.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=...) |