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:

Table 1. The four jobs of a response model
Job Effect

Validate

The returned value is validated against the declared type. If it is wrong (a missing field, a wrong type), FastAPI raises ResponseValidationError and the client receives a 500 Internal Server Error, never wrong data. This is a bug in your server, not in the request, which is why it is not a 422.

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 (TypeAdapter.dump_json), implemented in Rust, instead of the slower generic jsonable_encoder path (see Pydantic-in-Rust JSON Serialization).

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:

  • /rows returns dicts (with an internal_note key) and documents and filters them as list[Book]; the annotation is Any because the function really returns plain data.

  • /priority declares both → dict and response_model=Book: response_model wins, the annotation only serves tools.

  • /portal returns either a Response or a dict. That union is not a valid Pydantic field, so declaring it without response_model=None raises FastAPIError: Invalid args for response field! while the app is being built (the last lines of the script print that error). With response_model=None the annotation is kept for tools and FastAPI skips response-model generation, so the OpenAPI response has an empty schema ({}).

Table 2. Choosing between the two forms
Situation Use Why

The function returns instances of the model you want to publish

→ Book or → list[Book]

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)

→ BookBase

Tools accept a subclass where a base is expected; FastAPI still filters to the base fields (see Output Filtering: Keeping hashed_password Server-Side).

The function returns dicts or ORM objects and you want a model’s validation and docs

response_model=Book with → Any (or the real type)

The annotation describes reality; the decorator describes the contract.

The return type is not a valid Pydantic type (Response | dict, a database session object)

response_model=None

Disables response-model creation without losing the annotation.

Both are given

response_model= takes priority

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}
Table 3. Which declarations filter and which leak
Endpoint Declaration Result

/users/parent-annotation

→ UserBase returning a UserInDB

Filtered to UserBase fields. Tools are happy because UserInDB is a subclass of UserBase, and FastAPI does not use inheritance to widen the output.

/users/response-model

response_model=UserPublic and → Any

Filtered to UserPublic (adds id).

/users/leak-annotation

→ UserInDB

Leaks hashed_password: the declared model contains it.

/users/leak-none

response_model=None

Leaks: None turns the whole mechanism off, the object is serialized as-is.

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"])
Table 4. The response_model_* parameters
Parameter Default Effect (as run above)

response_model_exclude_unset

False

Drops fields that were not set explicitly in the returned data. unset/dune returns only title and isbn. A field set to the same value as its default is "set" (unset/same keeps description, price and tags).

response_model_exclude_defaults

False

Drops every field whose value equals its default, even when it was set explicitly (defaults/same returns only title and isbn).

response_model_exclude_none

False

Drops fields whose value is None (none/dune omits description and pageCount).

response_model_include

None

A set of field names; only those are sent (include/dune returns title and price).

response_model_exclude

None

A set of field names to remove (exclude/emma removes tags and description).

response_model_by_alias

True

Use the field alias (pageCount) as the key; False uses the attribute name (page_count). Compare default-alias/dune and alias/dune.

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_unset looks 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.

  • include and exclude are shortcuts. The OpenAPI schema stays that of the complete model, as the printed schema of /include/{key} shows (a $ref to the full Book), 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.

classDiagram class BookBase { title : str isbn : str pages : int or None } class BookCreate { input body of POST } class BookPublic { id : int output response_model } class BookInDB { id : int internal_cost : float stock_location : str never returned } BookBase <|-- BookCreate BookBase <|-- BookPublic BookBase <|-- BookInDB
Figure 1. The Book model family (In, Public, DB)
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"]))
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
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, so Union[…​] is no longer required there; the docs still call it out for older code); in an annotation, → Audiobook | Ebook works the same.

  • The official guide advises listing the more specific type first. In a quick run with a general Item and a more specific Plane (payload with size), Pydantic’s smart mode picked Plane in 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 Literal kind field with Field(discriminator="kind")) gives faster validation and unambiguous matching; see Pydantic v2 Essentials.

  • A union that mixes a Response with a model or dict is not valid for a response model: use response_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"])

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=204 and → None the response has an empty body, and OpenAPI lists a 204 without content. A 204 must not carry a body (RFC 9110), and FastAPI follows that when the declared type is None. Returning None explicitly with the return type None has been accepted since 0.117.0 (2025-09-20).

  • With the default 200 status, → None or no annotation at all sends the JSON literal null. If an empty body is what you mean, declare status_code=204.

  • → str | None serializes a missing value as null; 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 Response or 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, add responses= (see Documenting Other Responses) or response_class=.

  • The branch of get_book that returns a JSONResponse bypassed response_model=Book: the payload {"anything": "goes", "id": "not-an-int"} went out unvalidated. Returning a Response from an endpoint that also declares a model silently opts that branch out of the contract.

  • To put a model inside a JSONResponse you must convert it yourself with jsonable_encoder (so datetime, UUID and Decimal become 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 producing Item-Input and Item-Output; in this run it did not, because Pydantic’s default json_schema_serialization_defaults_required is False, 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 marks subtitle required, because the server always sends it (possibly as null), 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 with separate_input_output_schemas=False.

  • With False, Strict becomes 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 JSONResponse for speed" habit.

  • Custom JSON response classes such as ORJSONResponse and UJSONResponse are 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=None sends everything; see Output Filtering: Keeping hashed_password Server-Side. Test the response of every user-related endpoint for absent secret keys.

  • exclude_unset on instances. In the last example, /instance returned Book(title="Dune", subtitle=None, price=9.99) and, although response_model_exclude_unset=True, nothing was dropped (the response was title, subtitle: null and price) because every field had been passed to the constructor. /from-dict, returning {"title": "Dune"}, was trimmed to just title.

  • Schema and data disagree with include and exclude. The schema keeps all fields (see Controlling Serialization: exclude_unset, include, exclude and 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 ResponseValidationError and alert on it.

  • Inheritance does not widen the output. Annotating → UserBase and returning UserInDB filters correctly, but → UserInDB publishes everything declared there.

  • Union members that overlap. If two models accept the same data, which one matches is easy to misread; discriminate with a Literal field.

  • response_model on a Response. A returned Response bypasses 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 In and Public models from the start.

  • → None with the default 200. Sends null, not an empty body; use status_code=204 for "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; keep response_model= for the cases in the table above.

  • Pydantic v1 vocabulary. .dict(), .copy(update=…​), .json(), class Config with orm_mode = True, schema_extra and Field(…​, example=…​) are replaced by model_dump(), model_copy(update=…​), model_dump_json(), model_config = ConfigDict(from_attributes=True), json_schema_extra and examples=. Pydantic v1 support was removed in FastAPI 0.126.0 (2025-12-20) and pydantic.v1 in 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_schemas was added in 0.102.0 (2023-08-25), and its observable behaviour depends on the model’s json_schema_serialization_defaults_required setting and computed fields (see separate_input_output_schemas).

  • None as 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 ORJSONResponse and UJSONResponse were 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.