Custom Responses
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against FastAPI 0.142.2 (released 2026-09-30), Pydantic 2.13 and Starlette 1.7, checked on
2026-10-02. All examples use Annotated where parameters are declared, run on Python 3.10 or newer and were executed
with TestClient on Python 3.13. By default a path operation returns JSON: FastAPI validates and serializes whatever
you return, and the client receives application/json. Sometimes that is not what you want — an HTML fragment, a
CSV export, a file download, a redirect, a PNG chart, a custom header or a cookie. FastAPI exposes the Starlette
response classes for those cases, and this page explains the two ways to use them (return an instance, or declare a
class) and what each way costs you in validation and documentation. For response models and return types see
Response Models & Return Types; for status codes and error
responses see Status Codes & Error Handling; for streaming
bodies see Streaming, JSON Lines & SSE.
Two Ways to Control the Response
There are two independent mechanisms, and mixing them up is the most common source of confusion.
Return a Response object |
Declare response_class= |
|
|---|---|---|
What you write |
|
|
Validation and conversion |
None. FastAPI passes the object through untouched, even with a |
Applies the return type or |
OpenAPI documentation |
Nothing is generated from the object, so no media type is documented. |
The class’s |
Typical use |
Full control: custom status, headers, cookies, bodies you built yourself. |
A whole endpoint of one kind: HTML, plain text, CSV, a file path, a redirect URL. |
You can also combine them: declare response_class for the documentation and return an instance for the actual
bytes (see Declaring the Class and Returning an Instance). Everything on this page imports from fastapi.responses, which re-exports the Starlette
classes for convenience; from starlette.responses import … is identical. Response itself is also available as
fastapi.Response.
Returning a Response Directly, with jsonable_encoder
When you return a Response (or any subclass), FastAPI sends it as is. It does not validate the content, does not
convert it with a response_model, and does not document it. You take over the responsibility that FastAPI normally
carries: the content must already be in the right format for the class. For JSONResponse that means JSON-compatible
types only. A Pydantic model, a datetime or a UUID cannot go in directly.
jsonable_encoder converts any supported object (Pydantic models, dataclasses, datetime, UUID, sets, enums) to
plain dict/list/str/number values that the standard json module can serialize.
from datetime import datetime, timezone
from uuid import UUID
from fastapi import FastAPI
from fastapi.encoders import jsonable_encoder
from fastapi.responses import JSONResponse
from fastapi.testclient import TestClient
from pydantic import BaseModel
app = FastAPI()
class Book(BaseModel):
id: UUID
title: str
published_at: datetime
subtitle: str | None = None
@app.put("/books/{book_id}")
def replace_book(book_id: UUID, book: Book) -> JSONResponse:
return JSONResponse(content=jsonable_encoder(book), status_code=200)
@app.put("/broken/{book_id}")
def broken(book_id: UUID, book: Book) -> JSONResponse:
return JSONResponse(content=book.model_dump()) # datetime and UUID are not JSON types
c = TestClient(app, raise_server_exceptions=False)
body = {
"id": "6f1c1f9e-0d0f-4c0a-9a53-3e6b3b0a1f10",
"title": "Dune",
"published_at": "1965-08-01T00:00:00Z",
}
r = c.put("/books/6f1c1f9e-0d0f-4c0a-9a53-3e6b3b0a1f10", json=body)
print(r.status_code, r.headers["content-type"], r.json())
print(c.put("/broken/6f1c1f9e-0d0f-4c0a-9a53-3e6b3b0a1f10", json=body).status_code)
print(jsonable_encoder({"when": datetime(2026, 1, 2, tzinfo=timezone.utc), "n": {1, 2}}))
print(list(c.get("/openapi.json").json()["paths"]["/books/{book_id}"]["put"]["responses"]["200"]))
Output:
200 application/json {'id': '6f1c1f9e-0d0f-4c0a-9a53-3e6b3b0a1f10', 'title': 'Dune', 'published_at': '1965-08-01T00:00:00Z', 'subtitle': None}
500
{'when': '2026-01-02T00:00:00+00:00', 'n': [1, 2]}
['description', 'content']
The first request succeeds and returns the encoded model. The second endpoint puts book.model_dump() straight into
a JSONResponse; the datetime and UUID values are not JSON types, so serialization fails inside the response
and the client gets a 500. jsonable_encoder also turns a set into a list, as the third line shows. The last line
shows that the OpenAPI entry for 200 carries only a description and a generic content block, because the return
annotation JSONResponse is a Response subclass and is never used as a response model.
The official documentation notes that a response model gives you better performance than returning a
JSONResponse yourself, because with a return type or response_model FastAPI serializes with Pydantic (implemented
in Rust) and skips both jsonable_encoder and JSONResponse. Since 0.130.0 (2026-02-22) that direct serialization
is used whenever a return type or response model is set. Return an explicit JSONResponse only when you need
something a model cannot express: a non-default status together with custom headers, a body shaped at runtime, or a
response that should bypass validation on purpose.
The same call from a shell, against a running server (fastapi dev):
curl -s -X PUT localhost:8000/books/6f1c1f9e-0d0f-4c0a-9a53-3e6b3b0a1f10 \
-H 'Content-Type: application/json' \
-d '{"id": "6f1c1f9e-0d0f-4c0a-9a53-3e6b3b0a1f10", "title": "Dune", "published_at": "1965-08-01T00:00:00Z"}'
{"id":"6f1c1f9e-0d0f-4c0a-9a53-3e6b3b0a1f10","title":"Dune","published_at":"1965-08-01T00:00:00Z","subtitle":null}
The Built-in Response Classes
All classes derive from Response, which takes content (a str or bytes), status_code, headers and
media_type. Starlette adds Content-Length automatically and builds Content-Type from media_type, appending
charset=utf-8 for text types. The classes below differ in how they treat content and which media type they set.
| Class | Media type and behaviour | Used for |
|---|---|---|
|
Raw |
Anything not covered below: XML, PDF bytes, images. |
|
|
Explicit control over status code and headers. |
|
|
HTML pages and fragments. |
|
|
Health checks, simple text. |
|
Status |
Redirects after a form post or a moved resource. |
|
Streams a file asynchronously; sets |
Downloads of files that live on disk. |
|
Streams a generator or iterator. |
Large or unbounded bodies; see the streaming page. |
|
Deprecated since 0.131.0 (2026-02-22); see The |
Do not use in new code. |
HTMLResponse and PlainTextResponse
Declare the class in the decorator and return the string. The class sets the media type and FastAPI documents it in OpenAPI.
from fastapi import FastAPI
from fastapi.responses import HTMLResponse, PlainTextResponse, RedirectResponse
from fastapi.testclient import TestClient
app = FastAPI()
BOOKS = {1: "Dune", 2: "Emma"}
@app.get("/books/{book_id}/card", response_class=HTMLResponse)
def card(book_id: int) -> str:
return f"<h1>{BOOKS.get(book_id, 'unknown')}</h1>"
@app.get("/health", response_class=PlainTextResponse)
def health() -> str:
return "ok"
@app.get("/old-books/{book_id}")
def old(book_id: int) -> RedirectResponse:
return RedirectResponse(url=f"/books/{book_id}/card", status_code=301)
@app.get("/go", response_class=RedirectResponse)
def go() -> str:
return "/health"
@app.get("/go-302", response_class=RedirectResponse, status_code=302)
def go302() -> str:
return "/health"
c = TestClient(app)
for path in ["/books/1/card", "/health"]:
r = c.get(path)
print(path, r.status_code, r.headers["content-type"], repr(r.text))
for path in ["/old-books/2", "/go", "/go-302"]:
r = c.get(path, follow_redirects=False)
print(path, r.status_code, r.headers["location"])
r = c.get("/go")
print(r.status_code, r.url, r.text)
spec = c.get("/openapi.json").json()["paths"]
for p in ["/books/{book_id}/card", "/health", "/old-books/{book_id}", "/go"]:
print(p, {k: list(v["content"]) for k, v in [(k, v) for k, v in spec[p]["get"]["responses"].items() if "content" in v]})
Output:
/books/1/card 200 text/html; charset=utf-8 '<h1>Dune</h1>'
/health 200 text/plain; charset=utf-8 'ok'
/old-books/2 301 /books/2/card
/go 307 /health
/go-302 302 /health
200 http://testserver/health ok
/books/{book_id}/card {'200': ['text/html'], '422': ['application/json']}
/health {'200': ['text/plain']}
/old-books/{book_id} {'200': ['application/json'], '422': ['application/json']}
/go {}
The output shows the media type taking effect (text/html; charset=utf-8), and the OpenAPI document listing text/html
and text/plain as the 200 content types of the two endpoints. Use the return annotation str with these
classes; the string is the body, it is not JSON-encoded.
An HTML string built from user input is a cross-site scripting hole. FastAPI does not escape anything for you; render HTML with a template engine that auto-escapes (for example Jinja2 with auto-escaping) rather than with f-strings, as the card above does only because the title comes from a trusted dictionary.
Declaring the Class and Returning an Instance
If you return an HTMLResponse directly the actual Content-Type comes from the object, but nothing is documented
unless you also declare response_class=HTMLResponse. The declared class is then used only for the OpenAPI
document, while your returned object is sent as is. Use this combination when a helper builds the response (for
example to set a custom status code or headers) but you still want accurate API documentation.
from fastapi import FastAPI
from fastapi.responses import HTMLResponse
from fastapi.testclient import TestClient
app = FastAPI()
def build_card(title: str) -> HTMLResponse:
return HTMLResponse(content=f"<h1>{title}</h1>", status_code=200)
@app.get("/card", response_class=HTMLResponse)
def card() -> HTMLResponse:
return build_card("Dune")
c = TestClient(app)
r = c.get("/card")
print(r.status_code, r.headers["content-type"], r.text)
print(list(c.get("/openapi.json").json()["paths"]["/card"]["get"]["responses"]["200"]["content"]))
Output:
200 text/html; charset=utf-8 <h1>Dune</h1>
['text/html']
RedirectResponse
RedirectResponse(url, status_code=307, headers=None, background=None) sends a Location header. The default status
is 307 (Temporary Redirect), which tells the client to repeat the request with the same method and body. Pick the
status deliberately:
| Status | Meaning |
|---|---|
|
Moved permanently; clients may change |
|
Found (temporary); historically changes |
|
See Other: explicitly continue with a |
|
Temporary; method and body preserved. The default. |
|
Permanent; method and body preserved. |
You can return RedirectResponse(url=…) directly or declare response_class=RedirectResponse and return the URL
string, optionally with status_code=302 in the decorator, as the example above does. Two details from the run: the
/old-books/{book_id} endpoint is annotated with a RedirectResponse return type and is documented as returning
application/json, which is misleading, whereas the endpoint that declares response_class=RedirectResponse documents
no content at all. A response class without a media type is documented as having no body, which is right for a redirect.
Do not build the url from an unvalidated query parameter: that is an open redirect.
FileResponse
FileResponse(path, status_code=200, headers=None, media_type=None, background=None, filename=None, stat_result=None,
content_disposition_type="attachment") streams a file from disk without loading it into memory. If media_type is
not given it is guessed from the file name. Setting filename adds a Content-Disposition header so browsers offer a
download with that name; content_disposition_type="inline" asks the browser to display the file instead.
Starlette also sets Content-Length, Last-Modified and ETag, and the response answers Range requests with 206
(partial content), which is what lets video players seek.
import tempfile
from pathlib import Path
from fastapi import FastAPI
from fastapi.responses import FileResponse
from fastapi.testclient import TestClient
BASE = Path(tempfile.mkdtemp())
(BASE / "dune.txt").write_text("Fear is the mind-killer.\n" * 4)
(BASE / "dune.epub").write_bytes(b"PK\x03\x04fake")
app = FastAPI()
@app.get("/books/{name}/download")
def download(name: str) -> FileResponse:
return FileResponse(BASE / name, filename=name)
@app.get("/books/{name}/view", response_class=FileResponse)
def view(name: str) -> Path:
return BASE / name
@app.get("/books/{name}/inline")
def inline(name: str) -> FileResponse:
return FileResponse(BASE / name, filename=name, content_disposition_type="inline")
@app.get("/books/{name}/typed")
def typed(name: str) -> FileResponse:
return FileResponse(BASE / name, media_type="application/epub+zip")
c = TestClient(app)
r = c.get("/books/dune.txt/download")
print(r.status_code, {k: v for k, v in r.headers.items() if k in ("content-type", "content-disposition", "content-length")}, "etag" in r.headers, "last-modified" in r.headers)
print(r.headers.get("accept-ranges"))
r = c.get("/books/dune.txt/view")
print(r.status_code, r.headers["content-type"], "content-disposition" in r.headers)
print(c.get("/books/dune.txt/inline").headers["content-disposition"])
print(c.get("/books/dune.epub/typed").headers["content-type"])
print(c.get("/books/dune.epub/view").headers["content-type"])
r = c.get("/books/dune.txt/download", headers={"Range": "bytes=0-4"})
print(r.status_code, r.headers.get("content-range"), r.text)
Output:
200 {'content-type': 'text/plain; charset=utf-8', 'content-disposition': 'attachment; filename="dune.txt"', 'content-length': '100'} True True
bytes
200 text/plain; charset=utf-8 False
inline; filename="dune.txt"
application/epub+zip
application/epub+zip
206 bytes 0-4/100 Fear
The file name comes from the client in this example, which is a path traversal risk and fails with an unhandled
error when the file does not exist (Starlette raises a RuntimeError, which becomes a 500). A production endpoint
resolves the path, checks it stays below a base directory, and answers 404 for anything else:
import tempfile
from pathlib import Path
from typing import Annotated
from fastapi import FastAPI, HTTPException, Path as PathParam
from fastapi.responses import FileResponse
from fastapi.testclient import TestClient
BASE = Path(tempfile.mkdtemp()).resolve()
(BASE / "dune.txt").write_text("Fear is the mind-killer.\n")
app = FastAPI()
@app.get("/files/{name:path}")
def download(name: Annotated[str, PathParam(max_length=200)]) -> FileResponse:
target = (BASE / name).resolve()
if not target.is_relative_to(BASE) or not target.is_file():
raise HTTPException(status_code=404, detail="File not found")
return FileResponse(target, filename=target.name)
c = TestClient(app)
print(c.get("/files/dune.txt").status_code)
print(c.get("/files/missing.txt").status_code, c.get("/files/missing.txt").json())
print(c.get("/files/%2e%2e/%2e%2e/etc/passwd").status_code)
print(c.get("/files/..%2f..%2fetc/passwd").status_code)
Output:
200
404 {'detail': 'File not found'}
404
404
Both the plain .. form and the percent-encoded form are rejected. For user uploads that you serve back, see
Forms & File Uploads; for objects held in cloud storage prefer
signed URLs from the storage provider (see Amazon S3 Object Storage or
Azure Blob Storage) or a StreamingResponse.
A Custom Response Class
To change how the body is produced, subclass Response (or JSONResponse) and implement render(content), which
receives whatever the path operation returned and must return bytes. Set media_type as a class attribute. FastAPI
documents that media type in OpenAPI whenever the class is used as response_class. The official example formats
JSON with orjson; the version below needs no extra package and shows two typical uses: a CSV export and
indented JSON for debugging.
A class used through response_class that has a JSON media type still receives the data after FastAPI has applied
the return type or response model (converted with jsonable_encoder, not serialized by Pydantic), so declare custom
JSON classes only when you need the output format they give.
default_response_classimport csv
import io
import json
from typing import Any
from fastapi import APIRouter, FastAPI, Response
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.testclient import TestClient
class CSVResponse(Response):
media_type = "text/csv"
def render(self, content: Any) -> bytes:
buffer = io.StringIO()
writer = csv.writer(buffer)
for row in content:
writer.writerow(row)
return buffer.getvalue().encode("utf-8")
class PrettyJSONResponse(JSONResponse):
def render(self, content: Any) -> bytes:
return json.dumps(content, ensure_ascii=False, indent=2).encode("utf-8")
app = FastAPI()
@app.get("/export/books.csv", response_class=CSVResponse)
def export_books() -> list[list[str]]:
return [["id", "title"], ["1", "Dune"], ["2", "Emma, a novel"]]
@app.get("/debug/books", response_class=PrettyJSONResponse)
def debug_books() -> list[dict]:
return [{"id": 1, "title": "Dune"}]
c = TestClient(app)
r = c.get("/export/books.csv")
print(r.headers["content-type"]); print(r.text)
print(c.get("/debug/books").text)
print(c.get("/openapi.json").json()["paths"]["/export/books.csv"]["get"]["responses"]["200"]["content"])
# default_response_class
html_app = FastAPI(default_response_class=HTMLResponse)
router = APIRouter(prefix="/api", default_response_class=PrettyJSONResponse)
@html_app.get("/")
def home() -> str:
return "<h1>Bookshelf</h1>"
@html_app.get("/api-ish", response_class=JSONResponse)
def override() -> dict:
return {"ok": True}
@router.get("/books")
def books() -> list[dict]:
return [{"id": 1}]
html_app.include_router(router)
h = TestClient(html_app)
print(h.get("/").headers["content-type"])
print(h.get("/api-ish").headers["content-type"])
print(h.get("/api/books").text.replace("\n", "\\n"))
Output:
text/csv; charset=utf-8
id,title
1,Dune
2,"Emma, a novel"
[
{
"id": 1,
"title": "Dune"
}
]
{'text/csv': {'schema': {'type': 'string'}}}
text/html; charset=utf-8
application/json
[\n {\n "id": 1\n }\n]
default_response_class
Both FastAPI(…) and APIRouter(…) accept default_response_class, which replaces JSONResponse for every path
operation in that app or router that does not declare its own response_class. In the example the application-wide
default is HTMLResponse (/ returns text/html), a single route overrides it with response_class=JSONResponse,
and the router uses PrettyJSONResponse for all of its routes. An explicit response_class on a path operation always
wins over the router default, and a router default wins over the application default for the routes included from it.
Changing the default for a whole API is rarely a good idea for JSON services, because it turns off the direct Pydantic serialization path. It is a good fit for a router that only serves HTML pages.
Response Headers and Cookies
Headers and cookies can be set in two ways, matching the two mechanisms above:
-
declare a parameter of type
Responsein the path operation (or in a dependency) and mutate it. FastAPI copies the headers, cookies and status code from this temporary object onto the final response, which still carries the value you returned, filtered by anyresponse_model. -
build a response object yourself and pass
headers=or callset_cookieon it before returning it.
These two do not combine: when you return your own Response, FastAPI does not merge the temporary one into it.
from datetime import datetime, timedelta, timezone
from typing import Annotated
from fastapi import FastAPI, Response, Cookie
from fastapi.responses import JSONResponse
from fastapi.testclient import TestClient
from pydantic import BaseModel
app = FastAPI()
class BookOut(BaseModel):
id: int
title: str
DB = {1: {"id": 1, "title": "Dune", "internal_note": "do not leak"}}
@app.get("/books/{book_id}", response_model=BookOut)
def get_book(book_id: int, response: Response) -> dict:
response.headers["X-Cache"] = "miss"
response.headers["Cache-Control"] = "public, max-age=60"
response.status_code = 200
return DB[book_id]
@app.get("/direct")
def direct(response: Response) -> JSONResponse:
response.headers["X-Lost"] = "set on the parameter"
return JSONResponse({"ok": True}, headers={"X-Kept": "set on the response"})
@app.post("/login")
def login(response: Response) -> dict:
response.set_cookie(
key="session_id",
value="s-42",
max_age=3600,
path="/",
secure=True,
httponly=True,
samesite="strict",
)
return {"ok": True}
@app.post("/prefs")
def prefs() -> JSONResponse:
r = JSONResponse({"ok": True})
r.set_cookie("theme", "dark", expires=datetime.now(timezone.utc) + timedelta(days=30), samesite="lax")
r.set_cookie("tracking", "x", samesite="none", secure=True)
return r
@app.post("/logout")
def logout(response: Response, session_id: Annotated[str | None, Cookie()] = None) -> dict:
response.delete_cookie("session_id", path="/", secure=True, httponly=True, samesite="strict")
return {"ok": session_id is not None}
c = TestClient(app)
r = c.get("/books/1")
print(r.json(), r.headers["x-cache"], r.headers["cache-control"])
r = c.get("/direct"); print(r.headers.get("x-lost"), r.headers.get("x-kept"))
r = c.post("/login"); print(r.headers.get_list("set-cookie"))
r = c.post("/prefs");
for h in r.headers.get_list("set-cookie"): print(h)
r = c.post("/logout", cookies={"session_id": "s-42"}); print(r.json(), r.headers.get_list("set-cookie"))
Source: FastAPI: Response Headers
Source: FastAPI: Response Cookies
Output (the expires timestamps depend on the moment of the run):
{'id': 1, 'title': 'Dune'} miss public, max-age=60
None set on the response
['session_id=s-42; HttpOnly; Max-Age=3600; Path=/; SameSite=strict; Secure']
theme=dark; expires=Sun, 01 Nov 2026 08:39:30 GMT; Path=/; SameSite=lax
tracking=x; Path=/; SameSite=none; Secure
{'ok': True} ['session_id=""; expires=Fri, 02 Oct 2026 08:39:30 GMT; HttpOnly; Max-Age=0; Path=/; SameSite=strict; Secure']
The first line proves the response_model still filters the output (internal_note is gone) while the headers set on
the parameter arrive. The second line shows the pitfall: X-Lost, set on the parameter, vanished because the endpoint
returned its own JSONResponse; only X-Kept, passed to that object, was sent.
curl -si -X POST localhost:8000/login | grep -i '^set-cookie'
set-cookie: session_id=s-42; HttpOnly; Max-Age=3600; Path=/; SameSite=strict; Secure
(The cookie value s-42 is a placeholder. A real session identifier must be generated with secrets.token_urlsafe()
or issued by your authentication layer, never a counter or user id.)
set_cookie Parameters
Response.set_cookie(key, value="", max_age=None, expires=None, path="/", domain=None, secure=False, httponly=False,
samesite="lax", partitioned=False) comes from Starlette. Note the defaults: secure and httponly are off, which is
unsafe for a session cookie, so set them explicitly.
| Parameter | Meaning |
|---|---|
|
Cookie name and value (strings). |
|
Lifetime in seconds. |
|
Absolute expiry as a |
|
URL prefix the cookie is sent to. Defaults to |
|
Domain the cookie is valid for. Omit it to restrict the cookie to the exact host that set it. |
|
Send the cookie only over HTTPS. |
|
Hide the cookie from JavaScript ( |
|
|
|
Opt in to partitioned (CHIPS) cross-site cookies. Starlette documents it as available on Python 3.14 and newer only, and raises an error on older versions, so leave it off unless you target 3.14. |
delete_cookie(key, path="/", domain=None, secure=False, httponly=False, samesite="lax", partitioned=False) emits an
expired cookie (Max-Age=0, as the logout output above shows). It must use the same path and domain as the
original set_cookie, otherwise the browser keeps the original cookie. To read cookies back, see
Headers & Cookies.
Custom headers conventionally start with X-. A browser script on another origin can read only the CORS-safelisted
response headers plus those listed in the CORS expose_headers option, so a custom header you set here is invisible to
frontend JavaScript until you expose it (see
Middleware, CORS & Proxies).
The ORJSONResponse and UJSONResponse Deprecation
Older guides, and many FastAPI projects, speed up JSON by setting response_class=ORJSONResponse or
UJSONResponse, or default_response_class=ORJSONResponse on the application. Since FastAPI 0.131.0 (2026-02-22)
both classes are deprecated. The reason is 0.130.0 (2026-02-22): when a return type or a response_model is
declared, FastAPI now serializes the model to JSON bytes directly with Pydantic’s Rust core, which is as fast as
orjson and skips the intermediate jsonable_encoder step that a custom response class still needs.
import warnings
from fastapi import FastAPI
from fastapi.responses import ORJSONResponse
from fastapi.testclient import TestClient
from pydantic import BaseModel
app = FastAPI()
class Book(BaseModel):
id: int
title: str
@app.get("/old", response_class=ORJSONResponse)
def old() -> list[Book]:
return [Book(id=1, title="Dune")]
@app.get("/new")
def new() -> list[Book]:
return [Book(id=1, title="Dune")]
c = TestClient(app)
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
r_old = c.get("/old")
r_new = c.get("/new")
print([w.category.__name__ for w in caught if "ORJSON" in str(w.message)])
print(r_old.headers["content-type"], r_old.text)
print(r_new.headers["content-type"], r_new.text)
Output:
['FastAPIDeprecationWarning']
application/json [{"id":1,"title":"Dune"}]
application/json [{"id":1,"title":"Dune"}]
Using ORJSONResponse emits a FastAPIDeprecationWarning (a subclass of UserWarning) when the response is created,
at request time, not at import time, so it only shows up in test runs that hit the route. Migrating is a matter of
removing response_class (and default_response_class) and declaring return types such as → list[Book]. Both
endpoints produced identical bytes in the run above. If you need a feature of a third-party library that Pydantic does
not offer (for example, specific orjson options), write your own subclass as in A Custom Response Class; the package itself
is not deprecated, only the bundled FastAPI wrappers. The orjson package is not installed by FastAPI.
Returning an Image
An image is binary data with a media type, so the natural tool is a bare Response with media_type="image/png".
Generating a chart is the classic reason to do this: build the bytes in memory and return them without touching the
disk. Plotting libraries such as Matplotlib or Plotly write PNG bytes into a BytesIO buffer, which you pass to
Response(content=buffer.getvalue(), media_type="image/png"). To keep the example self-contained and free of heavy
dependencies, this one encodes a small grayscale bar chart of review ratings with the standard library alone.
Because a returned Response is not documented, the example declares response_class=Response and a responses=
entry so that OpenAPI (and Swagger UI) advertises an image/png body. A fixed image on
disk is simpler still: return a FileResponse.
import struct
import zlib
from io import BytesIO
from typing import Annotated
from fastapi import FastAPI, Query, Response
from fastapi.testclient import TestClient
app = FastAPI()
def make_png(width: int, height: int, rows: list[bytes]) -> bytes:
"""Encode 8-bit grayscale rows as a PNG using only the standard library."""
def chunk(kind: bytes, data: bytes) -> bytes:
body = kind + data
return struct.pack(">I", len(data)) + body + struct.pack(">I", zlib.crc32(body))
raw = b"".join(b"\x00" + row for row in rows)
header = struct.pack(">IIBBBBB", width, height, 8, 0, 0, 0, 0)
return b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", header) + chunk(b"IDAT", zlib.compress(raw)) + chunk(b"IEND", b"")
def rating_chart(ratings: list[int]) -> bytes:
"""A tiny bar chart: one 20-pixel-wide bar per rating (1 to 5)."""
height = 50
width = 20 * len(ratings)
rows = []
for y in range(height):
row = bytearray()
for rating in ratings:
filled = y >= height - rating * 10
row += bytes([40 if filled else 230]) * 20
rows.append(bytes(row))
return make_png(width, height, rows)
@app.get(
"/books/{book_id}/ratings.png",
response_class=Response,
responses={200: {"content": {"image/png": {}}, "description": "Rating bar chart"}},
)
def ratings_chart(book_id: int, n: Annotated[int, Query(ge=1, le=20)] = 5) -> Response:
png = rating_chart([(i % 5) + 1 for i in range(n)])
return Response(content=png, media_type="image/png", headers={"Cache-Control": "public, max-age=300"})
c = TestClient(app)
r = c.get("/books/1/ratings.png?n=5")
print(r.status_code, r.headers["content-type"], r.headers["content-length"], r.content[:8])
open("chart.png", "wb").write(r.content)
print(c.get("/books/1/ratings.png?n=0").status_code)
print(c.get("/openapi.json").json()["paths"]["/books/{book_id}/ratings.png"]["get"]["responses"]["200"])
Output:
200 image/png 122 b'\x89PNG\r\n\x1a\n'
422
{'description': 'Rating bar chart', 'content': {'image/png': {}}}
curl -s localhost:8000/books/1/ratings.png?n=8 -o ratings.png
The Cache-Control header in the example lets browsers and proxies reuse the image for five minutes, which suits a
chart that changes slowly. CPU-heavy rendering (large Matplotlib figures, for example) is blocking work: declare the
endpoint with plain def so FastAPI runs it in the thread pool, or move it off the event loop as described in
Async & Concurrency. Validate the size parameters (le=20 above)
so a client cannot request a gigantic image.
Where Older Guides Are Outdated, and What Changed Recently
-
ORJSONResponseeverywhere. Tutorials and the earlier version of the official docs recommendORJSONResponseorUJSONResponsefor speed. Both were deprecated in 0.131.0 (2026-02-22); declare a return type or response model instead (direct Pydantic serialization since 0.130.0, 2026-02-22). -
Plotting through temporary files. Book-style examples save a plot to disk and return a
FileResponse. Keeping the bytes in memory and returning aResponsewith an image media type avoids stale files and disk writes. -
Missing documentation. Returning a
Responsestill produces no response schema; the supported fix isresponse_classtogether withresponses=. -
Responsehas a defaultcharset. Text media types getcharset=utf-8appended by Starlette; old snippets that set the header by hand are redundant. -
Noneas a return type. Since 0.117.0 (2025-09-20)Noneis a valid return type for bodiless responses, which makesResponse(status_code=204)less necessary. -
Starlette 1.x. FastAPI supports Starlette 1.0 and newer since 0.133.0 (2026-02-24); the response classes and the
set_cookiesignature described here are the ones of Starlette 1.7.
Pitfalls
-
A returned
Responsebypasses theresponse_model: secrets in the object are not filtered. -
JSONResponse(content=model.model_dump())fails ondatetime,UUIDandDecimal; usejsonable_encoderormodel_dump(mode="json"). -
Headers set on the
Responseparameter are lost when you return your ownResponse. -
RedirectResponsedefaults to307, not302; use303after a formPOST. -
FileResponseraises when the file is missing and accepts any path you give it: validate the name and check it stays inside a base directory. -
A response class with no media type (such as
RedirectResponse) documents no body; a returnedResponsedocuments nothing at all. -
set_cookiedefaults tosecure=Falseandhttponly=False; session cookies need both, plus a deliberatesamesite. -
delete_cookiemust repeat the originalpathanddomain. -
default_response_class=HTMLResponseon the application turns every route without an explicit class into HTML, including ones that return dictionaries. -
ORJSONResponseandUJSONResponseemit deprecation warnings and are not needed for speed.