Custom Responses
FastAPI's default response is JSON. But real APIs sometimes need to return HTML, raw bytes, file downloads, streaming data, or redirects. FastAPI's response classes give you full control.
Learning Focus
By the end of this lesson you can: return JSONResponse with custom headers and status codes, serve file downloads with FileResponse, stream data with StreamingResponse, and redirect with RedirectResponse.
Response Class Overview
| Response class | Use case | Content-Type |
|---|---|---|
JSONResponse | Custom JSON with headers/status | application/json |
HTMLResponse | Return HTML pages | text/html |
PlainTextResponse | Return plain text | text/plain |
FileResponse | Download a file from disk | Detected from file |
StreamingResponse | Stream large or generated data | Configurable |
RedirectResponse | HTTP redirect | — |
Response | Raw response — full control | Configurable |
JSONResponse with Custom Headers
app/routers/data.py
from fastapi import APIRouter
from fastapi.responses import JSONResponse
router = APIRouter()
@router.get("/data")
async def get_data() -> JSONResponse:
return JSONResponse(
content={"key": "value"},
status_code=200,
headers={
"X-Request-ID": "abc-123",
"Cache-Control": "no-store",
},
)
HTMLResponse
app/routers/pages.py
from fastapi import APIRouter
from fastapi.responses import HTMLResponse
router = APIRouter()
@router.get("/hello", response_class=HTMLResponse)
async def hello_page() -> str:
return """
<html>
<head><title>Hello</title></head>
<body><h1>Hello from FastAPI</h1></body>
</html>
"""
FileResponse — File Downloads
app/routers/exports.py
import os
from fastapi import APIRouter, HTTPException
from fastapi.responses import FileResponse
router = APIRouter(prefix="/exports", tags=["exports"])
EXPORTS_DIR = "/var/app/exports"
@router.get("/{filename}")
async def download_export(filename: str) -> FileResponse:
# Sanitize filename to prevent path traversal
safe_name = os.path.basename(filename)
file_path = os.path.join(EXPORTS_DIR, safe_name)
if not os.path.isfile(file_path):
raise HTTPException(404, "File not found")
return FileResponse(
path=file_path,
filename=safe_name, # Sets Content-Disposition header
media_type="application/octet-stream",
)
StreamingResponse — Large Data
app/routers/stream.py
import asyncio
from typing import AsyncGenerator
from fastapi import APIRouter
from fastapi.responses import StreamingResponse
router = APIRouter()
async def generate_csv() -> AsyncGenerator[str, None]:
yield "id,name,price\n"
for i in range(1, 1001):
yield f"{i},Product {i},{i * 9.99:.2f}\n"
await asyncio.sleep(0) # Yield control to event loop
@router.get("/products.csv")
async def stream_products() -> StreamingResponse:
return StreamingResponse(
generate_csv(),
media_type="text/csv",
headers={"Content-Disposition": "attachment; filename=products.csv"},
)
RedirectResponse
app/routers/redirects.py
from fastapi import APIRouter
from fastapi.responses import RedirectResponse
router = APIRouter()
@router.get("/old-path")
async def old_endpoint() -> RedirectResponse:
return RedirectResponse(url="/new-path", status_code=301)
@router.get("/login-required")
async def protected() -> RedirectResponse:
# Temporarily redirect to login
return RedirectResponse(url="/auth/login", status_code=302)
Adding Headers to Any Response
Use the Response parameter to add headers without changing the response body type:
app/routers/items.py
from fastapi import APIRouter, Response
router = APIRouter()
@router.get("/items/{item_id}")
async def get_item(item_id: int, response: Response) -> dict:
response.headers["X-Item-ID"] = str(item_id)
response.headers["Cache-Control"] = "max-age=60"
return {"id": item_id, "name": "Widget"}
Common Pitfalls
| Pitfall | Cause / Symptom | Fix |
|---|---|---|
response_model ignored with JSONResponse | FastAPI does not validate JSONResponse content | Manually validate before constructing the response |
Path traversal in FileResponse | Unsanitized filename from user input | Use os.path.basename() and validate against allowed paths |
StreamingResponse closes mid-stream | Generator raises exception | Wrap generator body in try/except |
| Redirect loop | Redirecting /login to /login | Ensure redirect targets are different from the source route |
422 when returning Response | FastAPI tries to validate Response objects | Return Response subclass directly, no response_model needed |
Hands-On Practice
app/routers/reports.py
import csv
import io
from fastapi import APIRouter
from fastapi.responses import StreamingResponse, JSONResponse
router = APIRouter(prefix="/reports", tags=["reports"])
SAMPLE_DATA = [
{"id": 1, "product": "Widget", "sales": 1200, "revenue": 11988.00},
{"id": 2, "product": "Gadget", "sales": 450, "revenue": 20250.00},
{"id": 3, "product": "Doohickey", "sales": 800, "revenue": 7200.00},
]
@router.get("/summary.json")
async def json_summary() -> JSONResponse:
total = sum(r["revenue"] for r in SAMPLE_DATA)
return JSONResponse(
content={"records": len(SAMPLE_DATA), "total_revenue": total},
headers={"X-Report-Type": "summary"},
)
@router.get("/export.csv")
async def csv_export() -> StreamingResponse:
def generate():
output = io.StringIO()
writer = csv.DictWriter(output, fieldnames=["id", "product", "sales", "revenue"])
writer.writeheader()
for row in SAMPLE_DATA:
writer.writerow(row)
yield output.getvalue()
output.seek(0)
output.truncate(0)
return StreamingResponse(
generate(),
media_type="text/csv",
headers={"Content-Disposition": "attachment; filename=report.csv"},
)
test-reports.sh
uvicorn app.routers.reports:router --reload --port 8007
curl http://localhost:8007/reports/summary.json
curl http://localhost:8007/reports/export.csv -o report.csv && cat report.csv