Skip to main content

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 classUse caseContent-Type
JSONResponseCustom JSON with headers/statusapplication/json
HTMLResponseReturn HTML pagestext/html
PlainTextResponseReturn plain texttext/plain
FileResponseDownload a file from diskDetected from file
StreamingResponseStream large or generated dataConfigurable
RedirectResponseHTTP redirect
ResponseRaw response — full controlConfigurable

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

PitfallCause / SymptomFix
response_model ignored with JSONResponseFastAPI does not validate JSONResponse contentManually validate before constructing the response
Path traversal in FileResponseUnsanitized filename from user inputUse os.path.basename() and validate against allowed paths
StreamingResponse closes mid-streamGenerator raises exceptionWrap generator body in try/except
Redirect loopRedirecting /login to /loginEnsure redirect targets are different from the source route
422 when returning ResponseFastAPI tries to validate Response objectsReturn 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

What's Next