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.

Table 1. Return an instance versus declare a class
Return a Response object Declare response_class=

What you write

return HTMLResponse(content=html)

@app.get(…​, response_class=HTMLResponse) and return html

Validation and conversion

None. FastAPI passes the object through untouched, even with a response_model.

Applies the return type or response_model first, then puts the result in the class.

OpenAPI documentation

Nothing is generated from the object, so no media type is documented.

The class’s media_type is documented as the response content type.

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.

Returning a JSONResponse with jsonable_encoder
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.

Table 2. Response classes in fastapi.responses
Class Media type and behaviour Used for

Response

Raw str/bytes with the media_type you pass; no default media type.

Anything not covered below: XML, PDF bytes, images.

JSONResponse

application/json; serializes with the standard json module. The default when no response model is declared.

Explicit control over status code and headers.

HTMLResponse

text/html

HTML pages and fragments.

PlainTextResponse

text/plain

Health checks, simple text.

RedirectResponse

Status 307 by default, Location header from url; no media type.

Redirects after a form post or a moved resource.

FileResponse

Streams a file asynchronously; sets Content-Length, Last-Modified and ETag; guesses the media type.

Downloads of files that live on disk.

StreamingResponse

Streams a generator or iterator.

Large or unbounded bodies; see the streaming page.

ORJSONResponse, UJSONResponse

Deprecated since 0.131.0 (2026-02-22); see The ORJSONResponse and UJSONResponse Deprecation.

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.

HTMLResponse, PlainTextResponse and RedirectResponse
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:

Table 3. Choosing a redirect status code
Status Meaning

301

Moved permanently; clients may change POST to GET, and browsers cache it aggressively.

302

Found (temporary); historically changes POST to GET.

303

See Other: explicitly continue with a GET; the usual answer to a successful form POST.

307

Temporary; method and body preserved. The default.

308

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.

FileResponse: download, path return, inline and explicit media type
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:

A safe file download endpoint
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.

A CSV response, an indented JSON response and default_response_class
import 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 Response in 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 any response_model.

  • build a response object yourself and pass headers= or call set_cookie on it before returning it.

These two do not combine: when you return your own Response, FastAPI does not merge the temporary one into it.

Headers with a Response parameter, cookies with set_cookie and delete_cookie
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"))

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.)

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.

Table 4. set_cookie options
Parameter Meaning

key, value

Cookie name and value (strings).

max_age

Lifetime in seconds. 0 or a negative number discards the cookie immediately. Takes precedence over expires in browsers.

expires

Absolute expiry as a datetime (use a timezone-aware one), or a number of seconds until expiry.

path

URL prefix the cookie is sent to. Defaults to /.

domain

Domain the cookie is valid for. Omit it to restrict the cookie to the exact host that set it.

secure

Send the cookie only over HTTPS.

httponly

Hide the cookie from JavaScript (document.cookie), which limits the damage of an XSS bug.

samesite

"lax" (default), "strict" or "none". "none" requires secure=True in browsers and is for cross-site embeds.

partitioned

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.

Deprecated class versus a return type
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.

Returning a generated PNG
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

  • ORJSONResponse everywhere. Tutorials and the earlier version of the official docs recommend ORJSONResponse or UJSONResponse for 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 a Response with an image media type avoids stale files and disk writes.

  • Missing documentation. Returning a Response still produces no response schema; the supported fix is response_class together with responses=.

  • Response has a default charset. Text media types get charset=utf-8 appended by Starlette; old snippets that set the header by hand are redundant.

  • None as a return type. Since 0.117.0 (2025-09-20) None is a valid return type for bodiless responses, which makes Response(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_cookie signature described here are the ones of Starlette 1.7.

Pitfalls

  • A returned Response bypasses the response_model: secrets in the object are not filtered.

  • JSONResponse(content=model.model_dump()) fails on datetime, UUID and Decimal; use jsonable_encoder or model_dump(mode="json").

  • Headers set on the Response parameter are lost when you return your own Response.

  • RedirectResponse defaults to 307, not 302; use 303 after a form POST.

  • FileResponse raises 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 returned Response documents nothing at all.

  • set_cookie defaults to secure=False and httponly=False; session cookies need both, plus a deliberate samesite.

  • delete_cookie must repeat the original path and domain.

  • default_response_class=HTMLResponse on the application turns every route without an explicit class into HTML, including ones that return dictionaries.

  • ORJSONResponse and UJSONResponse emit deprecation warnings and are not needed for speed.