Response Models & Return Types
|
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 code uses Python 3.10+ syntax, Annotated where parameters need it and Pydantic v2, and every
example was executed with TestClient on Python 3.13. The response model is the contract of what an endpoint
sends back: FastAPI uses it to validate what your function returns, to filter out everything the model does not
declare (so a hashed_password never leaves the server), to document the response in OpenAPI and to serialize it
to JSON, since 0.130.0 in a single Rust pass inside Pydantic. The running scenario is the Bookshelf API (books,
authors, reviews, users). Declaring the input side is covered in
Request Body & Models, model internals in
Pydantic v2 Essentials, status codes and error bodies in
Status Codes & Error Handling, and the full set of
response classes in Custom Responses.
What a Response Model Does
When a path operation function has a response model, FastAPI does four things with whatever the function returns:
| Job | Effect |
|---|---|
Validate |
The returned value is validated against the declared type. If it is wrong (a missing field, a wrong type), FastAPI raises |
Filter |
Only the fields declared in the response type are sent. Extra attributes of the returned object or keys of the returned dict are dropped. |
Document |
A JSON Schema for the response is added to the path operation in OpenAPI, so Swagger UI, ReDoc and client generators know the shape (see OpenAPI & Interactive Docs). |
Serialize |
The validated data is converted to JSON by Pydantic ( |
There are two ways to declare the model: annotate the function’s return type, or pass response_model= to the
decorator. They are equivalent in effect; the next section explains when each is the right tool.
Return Annotation or response_model
Since FastAPI 0.89.0 (2023-01-07) a plain return annotation is enough. It is the preferred form because the same
annotation serves the editor, mypy or pyright and FastAPI.
from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import BaseModel
app = FastAPI()
class Book(BaseModel):
id: int
title: str
pages: int | None = None
BOOKS = [Book(id=1, title="Dune", pages=412), Book(id=2, title="Emma")]
@app.get("/books")
def list_books() -> list[Book]:
return BOOKS
@app.get("/books/{book_id}")
def get_book(book_id: int) -> Book:
return BOOKS[book_id - 1]
@app.get("/broken")
def broken() -> Book:
# the "database" row lacks the required "id" and has a bad type
return {"title": 123} # type: ignore[return-value]
c = TestClient(app, raise_server_exceptions=False)
print(c.get("/books").json())
print(c.get("/books/1").json())
r = c.get("/broken")
print(r.status_code, r.text)
spec = c.get("/openapi.json").json()
print(spec["paths"]["/books"]["get"]["responses"]["200"])
print(spec["components"]["schemas"]["Book"])
The list endpoint answers with the declared fields, including the optional pages of the second book as null:
curl http://127.0.0.1:8000/books
[{"id": 1, "title": "Dune", "pages": 412}, {"id": 2, "title": "Emma", "pages": null}]
/broken returns a dict that does not satisfy Book. The client sees only 500 Internal Server Error; on the
server ResponseValidationError is raised (and logged by the server), and TestClient re-raises it by default,
which is useful in tests:
from fastapi import FastAPI
from fastapi.exceptions import ResponseValidationError
from fastapi.testclient import TestClient
from pydantic import BaseModel
app = FastAPI()
class Book(BaseModel):
id: int
title: str
@app.get("/broken")
def broken() -> Book:
return {"title": 123} # type: ignore[return-value]
c = TestClient(app) # raise_server_exceptions=True is the default
try:
c.get("/broken")
except ResponseValidationError as exc:
for err in exc.errors():
print(err["type"], err["loc"], err["msg"])
missing ('response', 'id') Field required
string_type ('response', 'title') Input should be a valid string
The loc starts with "response" where request errors start with "body", "query" and so on.
The decorator parameter response_model= exists for the cases where the annotation cannot tell the whole story. It
takes the same kinds of types as a Pydantic field (a model, list[Model], dict[str, float], a Union, and so
on) and is accepted by every operation decorator (get, post, put, patch, delete, …).
from typing import Any
from fastapi import FastAPI, Response
from fastapi.responses import JSONResponse, RedirectResponse
from fastapi.testclient import TestClient
from pydantic import BaseModel
app = FastAPI()
class Book(BaseModel):
id: int
title: str
pages: int | None = None
ROWS = [{"id": 1, "title": "Dune", "pages": 412, "internal_note": "reorder"}]
# 1. dict rows, Pydantic documentation/validation/filtering
@app.get("/rows", response_model=list[Book])
def rows() -> Any:
return ROWS
# 2. response_model wins over the annotation
@app.get("/priority", response_model=Book)
def priority() -> dict:
return ROWS[0]
# 3. a union with Response is not a valid Pydantic field -> disable
@app.get("/portal", response_model=None)
def portal(teleport: bool = False) -> Response | dict:
if teleport:
return RedirectResponse("/rows")
return {"message": "no teleport"}
c = TestClient(app)
print(c.get("/rows").json())
print(c.get("/priority").json())
print(c.get("/portal").json())
r = c.get("/portal", params={"teleport": True}, follow_redirects=False)
print(r.status_code, r.headers["location"])
spec = c.get("/openapi.json").json()
print(spec["paths"]["/portal"]["get"]["responses"]["200"])
print(spec["paths"]["/rows"]["get"]["responses"]["200"])
# the failing variant
try:
bad = FastAPI()
@bad.get("/portal")
def portal_bad() -> Response | dict:
return {}
except Exception as exc: # fastapi.exceptions.FastAPIError
print(type(exc).__name__, str(exc)[:160])
The run produced these results, which show the three rules at work:
-
/rowsreturns dicts (with aninternal_notekey) and documents and filters them aslist[Book]; the annotation isAnybecause the function really returns plain data. -
/prioritydeclares both→ dictandresponse_model=Book:response_modelwins, the annotation only serves tools. -
/portalreturns either aResponseor a dict. That union is not a valid Pydantic field, so declaring it withoutresponse_model=NoneraisesFastAPIError: Invalid args for response field!while the app is being built (the last lines of the script print that error). Withresponse_model=Nonethe annotation is kept for tools and FastAPI skips response-model generation, so the OpenAPI response has an empty schema ({}).
| Situation | Use | Why |
|---|---|---|
The function returns instances of the model you want to publish |
|
One declaration, correct for type checkers, no duplication. |
The function returns a parent class on purpose to filter (a DB model whose annotation is the public base class) |
|
Tools accept a subclass where a base is expected; FastAPI still filters to the base fields (see Output Filtering: Keeping |
The function returns dicts or ORM objects and you want a model’s validation and docs |
|
The annotation describes reality; the decorator describes the contract. |
The return type is not a valid Pydantic type ( |
|
Disables response-model creation without losing the annotation. |
Both are given |
|
The annotation is then for tools only. |
A mixed case worth knowing is the Response family: an annotation that is exactly Response or a subclass
(RedirectResponse, JSONResponse, …) is recognised and handled automatically, with no response_model=None
needed; see Returning a Response Subclass.
Output Filtering: Keeping hashed_password Server-Side
The security value of a response model is that it is an allow-list: anything not declared never leaves. The canonical use is the user family, where the input carries a plaintext password, the database row carries its hash and the output carries neither.
from typing import Any
from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import BaseModel, EmailStr
app = FastAPI()
class UserBase(BaseModel):
username: str
email: EmailStr
full_name: str | None = None
class UserCreate(UserBase):
password: str
class UserInDB(UserBase):
hashed_password: str
class UserPublic(UserBase):
id: int
def to_db(user: UserCreate) -> UserInDB:
# stand-in for a real password hash such as Argon2 (see the security page)
return UserInDB(**user.model_dump(exclude={"password"}), hashed_password="hash:" + user.password[::-1])
# 1. The annotation is the parent class; the object returned is the subclass.
# FastAPI validates against UserBase, so hashed_password is dropped.
@app.post("/users/parent-annotation", status_code=201)
def create_user_1(user: UserCreate) -> UserBase:
return to_db(user)
# 2. response_model names the output model; the annotation is Any for tools.
@app.post("/users/response-model", response_model=UserPublic, status_code=201)
def create_user_2(user: UserCreate) -> Any:
return to_db(user).model_dump() | {"id": 7}
# 3. LEAK: the annotation is the DB model itself.
@app.post("/users/leak-annotation", status_code=201)
def create_user_3(user: UserCreate) -> UserInDB:
return to_db(user)
# 4. LEAK: response_model=None switches filtering off entirely.
@app.post("/users/leak-none", response_model=None, status_code=201)
def create_user_4(user: UserCreate) -> UserBase:
return to_db(user)
body = {"username": "ada", "email": "ada@example.com", "password": "s3cret-pass"}
c = TestClient(app)
for path in ("parent-annotation", "response-model", "leak-annotation", "leak-none"):
r = c.post(f"/users/{path}", json=body)
print(path, r.status_code, r.json())
schemas = c.get("/openapi.json").json()["components"]["schemas"]
print(sorted(k for k in schemas if k.startswith("User")))
The four endpoints receive the same payload:
curl -X POST http://127.0.0.1:8000/users/parent-annotation \
-H "Content-Type: application/json" \
-d '{"username": "ada", "email": "ada@example.com", "password": "s3cret-pass"}'
{"username": "ada", "email": "ada@example.com", "full_name": null}
| Endpoint | Declaration | Result |
|---|---|---|
|
|
Filtered to |
|
|
Filtered to |
|
|
Leaks |
|
|
Leaks: |
The two leaks are not bugs in FastAPI; they are the model doing exactly what you declared. Review rules that
follow from them: never use the DB model (or a model containing secrets) as a return type or response_model; treat
response_model=None as a red flag in code review; and put credentials only in the …InDB and …Create
models. In the OpenAPI document each model referenced by a request or response gets its own schema (UserCreate,
UserPublic, UserBase), so the generated docs show clients exactly which fields exist; UserInDB appears only
because endpoint 3 exposes it.
Hashing, verification and tokens are the topic of Security Fundamentals; here the hash is only a stand-in.
Controlling Serialization: exclude_unset, include, exclude and Friends
The decorator accepts a family of response_model_* parameters that forward to Pydantic’s serialization options.
They change the data sent, not the schema.
from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import BaseModel, Field
app = FastAPI()
class Book(BaseModel):
title: str
isbn: str
description: str | None = None
price: float = 9.99
tags: list[str] = []
page_count: int | None = Field(default=None, alias="pageCount")
DB = {
"dune": {"title": "Dune", "isbn": "9780441013593"},
"emma": {"title": "Emma", "isbn": "9780141439587", "description": "Austen", "price": 12.5},
"same": {"title": "Same", "isbn": "1", "description": None, "price": 9.99, "tags": []},
}
@app.get("/unset/{key}", response_model=Book, response_model_exclude_unset=True)
def unset(key: str):
return DB[key]
@app.get("/defaults/{key}", response_model=Book, response_model_exclude_defaults=True)
def defaults(key: str):
return DB[key]
@app.get("/none/{key}", response_model=Book, response_model_exclude_none=True)
def none(key: str):
return DB[key]
@app.get("/include/{key}", response_model=Book, response_model_include={"title", "price"})
def include(key: str):
return DB[key]
@app.get("/exclude/{key}", response_model=Book, response_model_exclude={"tags", "description"})
def exclude(key: str):
return DB[key]
@app.get("/alias/{key}", response_model=Book, response_model_by_alias=False)
def alias(key: str):
return {**DB[key], "pageCount": 300}
@app.get("/default-alias/{key}", response_model=Book)
def default_alias(key: str):
return {**DB[key], "pageCount": 300}
c = TestClient(app)
for path in ["unset/dune", "unset/emma", "unset/same", "defaults/same", "none/dune", "include/dune", "exclude/emma", "default-alias/dune", "alias/dune"]:
print(path, c.get("/" + path).json())
print(c.get("/openapi.json").json()["paths"]["/include/{key}"]["get"]["responses"]["200"])
| Parameter | Default | Effect (as run above) |
|---|---|---|
|
|
Drops fields that were not set explicitly in the returned data. |
|
|
Drops every field whose value equals its default, even when it was set explicitly ( |
|
|
Drops fields whose value is |
|
|
A set of field names; only those are sent ( |
|
|
A set of field names to remove ( |
|
|
Use the field alias ( |
response_model_include and response_model_exclude also accept a list or tuple, which FastAPI converts to a set.
They take Pydantic’s nested form too (for example \{"a": \{"x"}}) to reach fields of sub-models.
Two behaviours deserve care:
-
exclude_unsetlooks at how the returned object was built. A dict from a NoSQL document that holds only the stored keys works well. A model instance created with every argument spelled out (Book(title="Dune", subtitle=None, price=9.99)) counts every field as set, so nothing is dropped; the pitfalls section shows the run. -
includeandexcludeare shortcuts. The OpenAPI schema stays that of the complete model, as the printed schema of/include/{key}shows (a$refto the fullBook), so generated clients will expect fields that are never sent. Prefer separate models (BookPublic,BookSummary) whenever the shape is part of the contract, and use these parameters for quick, internal trimming.
The same goes for security: filtering by a deny-list (response_model_exclude={"hashed_password"}) fails open
when someone later adds a sensitive field, while an allow-list model fails closed.
Extra Models: the In, Public and DB Family
One entity rarely has one shape. A Book is created from a payload without an id, stored with an id and
internal columns, and published without the internal columns. Declare one small model per state and let
inheritance remove the duplication.
Shared fields live in the base; each subclass adds only what differs.
from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import BaseModel, Field
app = FastAPI()
class BookBase(BaseModel):
title: str = Field(min_length=1)
isbn: str
pages: int | None = None
class BookCreate(BookBase): # input
pass
class BookPublic(BookBase): # output
id: int
class BookInDB(BookBase): # persistence
id: int
internal_cost: float
stock_location: str
DB: dict[int, BookInDB] = {}
def save(book: BookCreate) -> BookInDB:
row = BookInDB(**book.model_dump(), id=len(DB) + 1, internal_cost=4.2, stock_location="A-12")
DB[row.id] = row
return row
@app.post("/books", response_model=BookPublic, status_code=201)
def create(book: BookCreate):
return save(book)
@app.get("/books", response_model=list[BookPublic])
def list_all():
return list(DB.values())
class Audiobook(BaseModel):
kind: str = "audiobook"
title: str
minutes: int
class Ebook(BaseModel):
kind: str = "ebook"
title: str
format: str
@app.get("/formats/{key}", response_model=Audiobook | Ebook)
def fmt(key: str):
return {"audio": {"title": "Dune", "minutes": 1260}, "epub": {"title": "Dune", "format": "epub"}}[key]
@app.get("/stats/ratings", response_model=dict[str, float])
def ratings():
return {"Dune": 4.5, "Emma": 4.1}
c = TestClient(app)
print(c.post("/books", json={"title": "Dune", "isbn": "9780441013593"}).json())
print(c.get("/books").json())
print(c.get("/formats/audio").json(), c.get("/formats/epub").json())
print(c.get("/stats/ratings").json())
spec = c.get("/openapi.json").json()
print(spec["paths"]["/formats/{key}"]["get"]["responses"]["200"]["content"]["application/json"]["schema"])
print(spec["paths"]["/stats/ratings"]["get"]["responses"]["200"]["content"]["application/json"]["schema"])
print(sorted(spec["components"]["schemas"]))
Source: FastAPI: Extra Models
curl -X POST http://127.0.0.1:8000/books \
-H "Content-Type: application/json" \
-d '{"title": "Dune", "isbn": "9780441013593"}'
{"title": "Dune", "isbn": "9780441013593", "pages": null, "id": 1}
save() built a BookInDB that also held internal_cost and stock_location; BookPublic removed them. The
OpenAPI components list only BookCreate and BookPublic (plus the union members), because BookInDB is not
used by any endpoint declaration.
Building One Model from Another
Three common ways to turn the input into the DB model:
from pydantic import BaseModel
class BookBase(BaseModel):
title: str
isbn: str
class BookCreate(BookBase):
pass
class BookInDB(BookBase):
id: int
internal_cost: float
payload = BookCreate(title="Dune", isbn="9780441013593")
# 1. unpack the dict and add the extra fields (keyword arguments)
a = BookInDB(**payload.model_dump(), id=1, internal_cost=4.2)
# 2. merge dicts, then validate (works when the extra data is itself a dict)
b = BookInDB.model_validate(payload.model_dump() | {"id": 2, "internal_cost": 4.2})
# 3. model_copy(update=...) does NOT validate and keeps the SAME class
c = payload.model_copy(update={"title": "Dune Messiah"})
print(a)
print(b)
print(type(c).__name__, c)
print(payload.model_copy(update={"id": 3}).model_dump()) # an unknown key is accepted silently
Source: FastAPI: Extra Models
title='Dune' isbn='9780441013593' id=1 internal_cost=4.2
title='Dune' isbn='9780441013593' id=2 internal_cost=4.2
BookCreate title='Dune Messiah' isbn='9780441013593'
Unpacking model_dump() and model_validate both validate and build the target class; model_copy(update=…)
keeps the same class and does not validate, so it cannot add fields of a different model. Older code spells these
.dict() and .copy(update=…), which are the removed Pydantic v1 names.
Union, Lists and Dicts
response_model (and the return annotation) accepts any type Pydantic can validate. A union is documented as
anyOf; a list as an array; a dict[str, float] as an object with additionalProperties. The run above shows:
{"anyOf": [{"$ref": "#/components/schemas/Audiobook"}, {"$ref": "#/components/schemas/Ebook"}], "title": "Response Fmt Formats Key Get"}
{"additionalProperties": {"type": "number"}, "type": "object", "title": "Response Ratings Stats Ratings Get"}
Notes on unions:
-
In a decorator, write
response_model=Audiobook | Ebook(Python 3.10+ evaluates|on classes, soUnion[…]is no longer required there; the docs still call it out for older code); in an annotation,→ Audiobook | Ebookworks the same. -
The official guide advises listing the more specific type first. In a quick run with a general
Itemand a more specificPlane(payload withsize), Pydantic’s smart mode pickedPlanein both orders, so the order did not matter there; keep the habit anyway, because it makes the intent explicit and helps when two members accept the same data. -
A discriminated union (a
Literalkindfield withField(discriminator="kind")) gives faster validation and unambiguous matching; see Pydantic v2 Essentials. -
A union that mixes a
Responsewith a model or dict is not valid for a response model: useresponse_model=None.
A dict response (dict[str, float]) is the right choice when the keys are data (here, book titles) and you cannot
name the fields in a model.
Returning ORM Objects
Returning an object with attributes (a SQLAlchemy or SQLModel instance) works with a response model:
from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import BaseModel, ConfigDict
app = FastAPI()
class BookRow: # stands in for an ORM instance (e.g. a SQLAlchemy object)
def __init__(self, id: int, title: str, hashed_secret: str):
self.id = id
self.title = title
self.hashed_secret = hashed_secret
class BookPublic(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
title: str
class BookPlain(BaseModel):
id: int
title: str
@app.get("/with", response_model=BookPublic)
def with_attrs():
return BookRow(1, "Dune", "x")
@app.get("/without", response_model=BookPlain)
def without_attrs():
return BookRow(1, "Dune", "x")
c = TestClient(app, raise_server_exceptions=False)
print(c.get("/with").json())
r = c.get("/without")
print(r.status_code, r.text)
{"id": 1, "title": "Dune"}
{"id":1,"title":"Dune"}
Both endpoints answered 200 with the filtered fields (hashed_secret is never sent). In this run FastAPI 0.142.2
validated the returned object by attributes even without from_attributes=True on the response model. Setting
model_config = ConfigDict(from_attributes=True) (the v2 name for v1’s orm_mode) is still the explicit,
portable way to document that a model is built from attributes, and it is required to call
Model.model_validate(obj) yourself. The SQL pages show the persisted version:
SQL Databases with SQLModel.
None Returns and Empty Bodies
Operations that have nothing to say (deleting, triggering a job) should say so in the type:
from fastapi import FastAPI
from fastapi.testclient import TestClient
app = FastAPI()
BOOKS = {1: "Dune"}
@app.delete("/books/{book_id}", status_code=204)
def delete_book(book_id: int) -> None:
BOOKS.pop(book_id, None)
@app.post("/books/{book_id}/touch")
def touch(book_id: int) -> None: # default 200
return None
@app.get("/books/{book_id}/maybe")
def maybe(book_id: int) -> str | None:
return BOOKS.get(book_id)
@app.get("/books/{book_id}/untyped")
def untyped(book_id: int):
return None
c = TestClient(app)
r = c.delete("/books/1")
print(r.status_code, repr(r.content), r.headers.get("content-length"), r.headers.get("content-type"))
r = c.post("/books/1/touch")
print(r.status_code, repr(r.content), r.headers.get("content-type"))
print(c.get("/books/1/maybe").json(), c.get("/books/2/maybe").json())
r = c.get("/books/1/untyped")
print(r.status_code, repr(r.content))
spec = c.get("/openapi.json").json()
print(spec["paths"]["/books/{book_id}"]["delete"]["responses"]["204"])
print(spec["paths"]["/books/{book_id}/touch"]["post"]["responses"]["200"])
Source: FastAPI: Response Status Code
Output of the run:
204 b'' None application/json
200 b'null' application/json
None None
200 b'null'
{'description': 'Successful Response'}
{'description': 'Successful Response', 'content': {'application/json': {'schema': {}}}}
-
With
status_code=204and→ Nonethe response has an empty body, and OpenAPI lists a204without content. A204must not carry a body (RFC 9110), and FastAPI follows that when the declared type isNone. ReturningNoneexplicitly with the return typeNonehas been accepted since 0.117.0 (2025-09-20). -
With the default
200status,→ Noneor no annotation at all sends the JSON literalnull. If an empty body is what you mean, declarestatus_code=204. -
→ str | Noneserializes a missing value asnull; the field is still documented as nullable. -
The DELETE and create conventions (201, 204) are explained in Status Codes & Error Handling.
Returning a Response Subclass
A path operation can return a ready-made Response (or a subclass such as JSONResponse, RedirectResponse,
PlainTextResponse, FileResponse). FastAPI passes it through untouched: there is no validation, no filtering, no
jsonable_encoder conversion. You gain full control (media type, headers, status code, bytes) and take on full
responsibility for the content.
from fastapi import FastAPI, Response
from fastapi.responses import JSONResponse, PlainTextResponse, RedirectResponse
from fastapi.testclient import TestClient
from pydantic import BaseModel
app = FastAPI()
class Book(BaseModel):
id: int
title: str
@app.get("/books/{book_id}/export")
def export(book_id: int) -> Response:
return Response(content="id,title\n1,Dune\n", media_type="text/csv")
@app.get("/books/{book_id}/go")
def go(book_id: int) -> RedirectResponse:
return RedirectResponse(f"/books/{book_id}/export")
@app.get("/books/{book_id}/text")
def text(book_id: int) -> PlainTextResponse:
return PlainTextResponse("Dune")
@app.get("/books/{book_id}", response_model=Book)
def get_book(book_id: int):
if book_id == 0:
# a Response is passed through: NO response_model validation or filtering
return JSONResponse({"anything": "goes", "id": "not-an-int"})
return {"id": book_id, "title": "Dune", "secret": "x"}
c = TestClient(app)
r = c.get("/books/1/export")
print(r.status_code, r.headers["content-type"], repr(r.text))
r = c.get("/books/1/go", follow_redirects=False)
print(r.status_code, r.headers["location"])
print(c.get("/books/1").json())
print(c.get("/books/0").json())
spec = c.get("/openapi.json").json()
print(spec["paths"]["/books/{book_id}/export"]["get"]["responses"]["200"])
print(spec["paths"]["/books/{book_id}"]["get"]["responses"]["200"])
Output of the run:
200 text/csv; charset=utf-8 'id,title\n1,Dune\n'
307 /books/1/export
{'id': 1, 'title': 'Dune'}
{'anything': 'goes', 'id': 'not-an-int'}
{'description': 'Successful Response'}
{'description': 'Successful Response', 'content': {'application/json': {'schema': {'$ref': '#/components/schemas/Book'}}}}
Three consequences of the behaviour above:
-
Annotating the function with
Responseor a subclass (→ RedirectResponse) is recognised by FastAPI and creates no response model. OpenAPI shows a generic successful response without a schema, because FastAPI cannot know what you put inside it. If you want the document to describe it, addresponses=(see Documenting Other Responses) orresponse_class=. -
The branch of
get_bookthat returns aJSONResponsebypassedresponse_model=Book: the payload{"anything": "goes", "id": "not-an-int"}went out unvalidated. Returning aResponsefrom an endpoint that also declares a model silently opts that branch out of the contract. -
To put a model inside a
JSONResponseyou must convert it yourself withjsonable_encoder(sodatetime,UUIDandDecimalbecome JSON types);JSONResponse(content=model)fails. See Custom Responses.
Performance note: the official guide points out that a response model normally beats returning a JSONResponse
yourself, because Pydantic serializes in Rust (see Pydantic-in-Rust JSON Serialization). Return a Response for what a model
cannot express (CSV, XML, redirects, files), not as a speed trick.
Documenting Other Responses
The responses= decorator parameter adds extra status codes and models to OpenAPI. It documents; it does not
validate. The shipped HTTPException produces {"detail": …}, so a matching model keeps docs and behaviour
aligned:
from fastapi import FastAPI, HTTPException
from fastapi.testclient import TestClient
from pydantic import BaseModel
app = FastAPI()
class Book(BaseModel):
title: str
subtitle: str | None = None
price: float = 9.99
class Problem(BaseModel):
detail: str
@app.get("/instance", response_model=Book, response_model_exclude_unset=True)
def instance():
# every field was passed to the constructor -> every field counts as "set"
return Book(title="Dune", subtitle=None, price=9.99)
@app.get("/from-dict", response_model=Book, response_model_exclude_unset=True)
def from_dict():
return {"title": "Dune"}
@app.get("/books/{book_id}", response_model=Book, responses={404: {"model": Problem, "description": "Book not found"}})
def get_book(book_id: int):
if book_id != 1:
raise HTTPException(status_code=404, detail="Book not found")
return {"title": "Dune"}
c = TestClient(app)
print(c.get("/instance").json())
print(c.get("/from-dict").json())
print(c.get("/books/2").status_code, c.get("/books/2").json())
spec = c.get("/openapi.json").json()
print(sorted(spec["paths"]["/books/{book_id}"]["get"]["responses"]))
print(spec["paths"]["/books/{book_id}"]["get"]["responses"]["404"])
The OpenAPI responses of /books/{book_id} are 200, 404 (with the Problem schema and description "Book not
found") and the automatic 422.
separate_input_output_schemas
Pydantic can describe the same model differently for validation (what clients may send) and serialization (what
the server sends). FastAPI exposes both: when the two JSON Schemas differ, OpenAPI contains a Name-Input and a
Name-Output schema, and generated clients get more exact types. The switch has existed since 0.102.0
(2023-08-25); FastAPI(separate_input_output_schemas=False) goes back to a single schema (the validation one) when
you cannot change already generated clients yet.
from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import BaseModel, ConfigDict, computed_field
class Plain(BaseModel):
title: str
subtitle: str | None = None
class Strict(BaseModel):
model_config = ConfigDict(json_schema_serialization_defaults_required=True)
title: str
subtitle: str | None = None
class WithTitleCase(BaseModel):
title: str
@computed_field
@property
def shout(self) -> str:
return self.title.upper()
def build(**kw) -> FastAPI:
app = FastAPI(**kw)
@app.post("/plain")
def plain(book: Plain) -> Plain:
return book
@app.post("/strict")
def strict(book: Strict) -> Strict:
return book
@app.post("/computed")
def computed(book: WithTitleCase) -> WithTitleCase:
return book
return app
for kw in ({}, {"separate_input_output_schemas": False}):
schemas = TestClient(build(**kw)).get("/openapi.json").json()["components"]["schemas"]
print(kw or "default (True)")
for name in sorted(k for k in schemas if not k.endswith("Error")):
print(f" {name}: required={schemas[name].get('required')}")
default (True)
Plain: required=['title']
Strict-Input: required=['title']
Strict-Output: required=['title', 'subtitle']
WithTitleCase-Input: required=['title']
WithTitleCase-Output: required=['title', 'shout']
{'separate_input_output_schemas': False}
Plain: required=['title']
Strict: required=['title']
WithTitleCase-Input: required=['title']
WithTitleCase-Output: required=['title', 'shout']
What this run shows, on FastAPI 0.142.2 with Pydantic 2.13.5:
-
A plain model with a default (
Plain,subtitle: str | None = None) produced a single schema even with the default setting. The official how-to page describes exactly this case as producingItem-InputandItem-Output; in this run it did not, because Pydantic’s defaultjson_schema_serialization_defaults_requiredisFalse, so a default does not make a field required in the output schema. Treat the how-to page’s screenshots as the behaviour you get when that option is on. -
A model that sets
ConfigDict(json_schema_serialization_defaults_required=True)(Strict) is split: the output schema markssubtitlerequired, because the server always sends it (possibly asnull), and clients can rely on the key being present. -
A model with a
@computed_field(WithTitleCase) is always split, because the output has a field that the input does not; FastAPI keeps that split even withseparate_input_output_schemas=False. -
With
False,Strictbecomes one schema again and shows only the validation requirements.
When to turn it off: only to keep an existing generated client stable. When you generate clients from the OpenAPI document, leave it on, so the generated output types say which keys are always present.
Pydantic-in-Rust JSON Serialization
When an endpoint has a response model (return annotation or response_model), FastAPI validates the returned value
and then calls Pydantic’s TypeAdapter.dump_json, which serializes straight to JSON bytes in Pydantic’s Rust core.
Since 0.130.0 (2026-02-22) this is the default path; earlier versions went through dump_python and then Python’s
JSON encoder. Without a response model FastAPI falls back to jsonable_encoder plus JSONResponse, all in Python.
The practical rule: a typed return is also the fast return.
A measurement on one laptop (Python 3.13, in-process TestClient, list of 2000 Review models; the numbers are
indicative and depend on your machine and data):
import timeit
from fastapi import FastAPI
from fastapi.encoders import jsonable_encoder
from fastapi.responses import JSONResponse
from fastapi.testclient import TestClient
from pydantic import BaseModel
class Review(BaseModel):
id: int
book_id: int
rating: int
comment: str
tags: list[str]
ROWS = [Review(id=i, book_id=i % 50, rating=i % 5 + 1, comment="Great read " * 5, tags=["a", "b", "c"]) for i in range(2000)]
app = FastAPI()
@app.get("/typed")
def typed() -> list[Review]:
return ROWS
@app.get("/untyped")
def untyped():
return ROWS
@app.get("/manual")
def manual():
return JSONResponse(jsonable_encoder(ROWS))
c = TestClient(app)
assert c.get("/typed").json() == c.get("/untyped").json() == c.get("/manual").json()
for path in ("typed", "untyped", "manual"):
t = min(timeit.repeat(lambda: c.get("/" + path), number=20, repeat=5)) / 20
print(f"{path:8s} {t * 1000:7.2f} ms per request (2000 reviews, TestClient, in-process)")
typed 1.12 ms per request (2000 reviews, TestClient, in-process)
untyped 10.67 ms per request (2000 reviews, TestClient, in-process)
manual 10.79 ms per request (2000 reviews, TestClient, in-process)
The typed endpoint was roughly ten times faster than the untyped and the manual JSONResponse(jsonable_encoder(…))
variants, and all three produced identical JSON (the script asserts it). The consequences:
-
Annotate return types and drop the "return a
JSONResponsefor speed" habit. -
Custom JSON response classes such as
ORJSONResponseandUJSONResponseare no longer a performance recommendation: they were deprecated in 0.131.0 (2026-02-22) because the built-in path is faster. See Custom Responses. -
Validation still runs on the returned data, so for very large payloads the cost is validation plus serialization; filtering to fewer fields and paginating (see Query Parameters) help more than any serializer.
Pitfalls
-
Leaking by declaring the wrong model. A DB model or
response_model=Nonesends everything; see Output Filtering: Keepinghashed_passwordServer-Side. Test the response of every user-related endpoint for absent secret keys. -
exclude_unseton instances. In the last example,/instancereturnedBook(title="Dune", subtitle=None, price=9.99)and, althoughresponse_model_exclude_unset=True, nothing was dropped (the response wastitle,subtitle: nullandprice) because every field had been passed to the constructor./from-dict, returning{"title": "Dune"}, was trimmed to justtitle. -
Schema and data disagree with
includeandexclude. The schema keeps all fields (see Controlling Serialization:exclude_unset,include,excludeand Friends). -
A returned value that does not match fails the request with 500. That is the point of validation, but remember that existing rows with legacy data can start failing after a model gets stricter. Log
ResponseValidationErrorand alert on it. -
Inheritance does not widen the output. Annotating
→ UserBaseand returningUserInDBfilters correctly, but→ UserInDBpublishes everything declared there. -
Union members that overlap. If two models accept the same data, which one matches is easy to misread; discriminate with a
Literalfield. -
response_modelon aResponse. A returnedResponsebypasses the model, including in only one branch of a function. -
Reusing the input model as the output. It works until the input gains a write-only field (a password, a token). Keep separate
InandPublicmodels from the start. -
→ Nonewith the default 200. Sendsnull, not an empty body; usestatus_code=204for "no content".
Where Older Guides Are Outdated, and What Changed Recently
-
Return annotations. Older tutorials always pass
response_model=and annotate nothing. Since 0.89.0 (2023-01-07) a return annotation is enough and preferred; keepresponse_model=for the cases in the table above. -
Pydantic v1 vocabulary.
.dict(),.copy(update=…),.json(),class Configwithorm_mode = True,schema_extraandField(…, example=…)are replaced bymodel_dump(),model_copy(update=…),model_dump_json(),model_config = ConfigDict(from_attributes=True),json_schema_extraandexamples=. Pydantic v1 support was removed in FastAPI 0.126.0 (2025-12-20) andpydantic.v1in 0.128.0 (2025-12-27). The mapping is in Pydantic v2 Essentials. -
Output filtering through a single model. Guides that return the stored object and rely on
response_model_exclude={"password"}use a deny-list; define the output model instead. -
Separate input and output schemas. New since the Pydantic v2 migration; the switch
separate_input_output_schemaswas added in 0.102.0 (2023-08-25), and its observable behaviour depends on the model’sjson_schema_serialization_defaults_requiredsetting and computed fields (seeseparate_input_output_schemas). -
Noneas a return type. Allowed for bodiless responses since 0.117.0 (2025-09-20). -
Rust serialization. Since 0.130.0 (2026-02-22) typed responses are serialized by Pydantic directly to JSON, which is why
ORJSONResponseandUJSONResponsewere deprecated in 0.131.0 (2026-02-22). -
Starlette 1.x. Supported since 0.133.0 (2026-02-24); nothing in the response-model API changed for that.