Request Body & Models

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 and Pydantic v2, and was executed with TestClient on Python 3.13. A request body is the data a client sends to the API (almost always JSON in a POST, PUT or PATCH); you declare its shape with a Pydantic model and FastAPI reads, converts, validates, documents and hands it to your function as a typed object. The Bookshelf API (books, authors, reviews) is the running scenario. How the same models are written as Pydantic (validators, model_config, serialization) is the subject of Pydantic v2 Essentials; how the response side works is covered in Response Models & Return Types.

A Pydantic Model as the Body

Declare a class that inherits from pydantic.BaseModel, with one annotated attribute per JSON key, and use it as the type of a handler parameter. Attributes without a default are required; attributes with a default (including None) are optional.

from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import BaseModel

app = FastAPI()


class BookIn(BaseModel):
    title: str
    isbn: str
    pages: int | None = None
    price: float = 9.99


@app.post("/books")
def create_book(book: BookIn) -> dict:
    # book is a validated BookIn instance, not a dict
    return {"title": book.title, "price_with_tax": round(book.price * 1.21, 2), "type": type(book).__name__}


c = TestClient(app)
r = c.post("/books", json={"title": "Dune", "isbn": "9780441013593"})
print(r.status_code, r.json())
r = c.post("/books", json={"title": "Dune"})
print(r.status_code, r.json())
r = c.post("/books", json={"title": "Dune", "isbn": "x", "pages": "many"})
print(r.status_code, r.json())
r = c.post("/books", content="{bad json", headers={"content-type": "application/json"})
print(r.status_code, r.json())
r = c.post("/books", json=["a"])
print(r.status_code, r.json())
r = c.post("/books", json={"title": "Dune", "isbn": "1", "color": "red"})
print(r.status_code, r.json())
spec = c.get("/openapi.json").json()
print(spec["paths"]["/books"]["post"]["requestBody"])
print(spec["components"]["schemas"]["BookIn"])

With a valid payload, create_book receives a real BookIn instance (the handler reports its class name), so attribute access, completion and static checking work:

curl -X POST http://127.0.0.1:8000/books \
  -H "Content-Type: application/json" \
  -d '{"title": "Dune", "isbn": "9780441013593"}'
{"title": "Dune", "price_with_tax": 12.09, "type": "BookIn"}

For each kind of mistake the client gets a 422 Unprocessable Content response whose detail is a list of error objects (type, loc, msg, input, and sometimes ctx). loc starts with "body" followed by the path to the offending value. The runs above produced:

Table 1. Typical 422 errors for a body
Request type and loc msg

missing required field

missing, ["body", "isbn"]

Field required

wrong type for a field

int_parsing, ["body", "pages"]

Input should be a valid integer, unable to parse string as an integer

malformed JSON text

json_invalid, ["body", 1] (the position of the error)

JSON decode error

JSON array instead of object

model_attributes_type, ["body"]

Input should be a valid dictionary or object to extract fields from

{"detail": [{"type": "missing", "loc": ["body", "isbn"], "msg": "Field required", "input": {"title": "Dune"}}]}

Two behaviours of the default configuration are worth knowing. First, unknown keys are ignored: the call with a color key in the example sent "color": "red" and still got a 200. Set extra="forbid" in the model’s model_config when you want typos rejected (shown in Pitfalls). Second, Pydantic converts compatible values in its default "lax" mode: "412" becomes 412 for an int, while "many" is rejected.

The OpenAPI document gets a requestBody with required: true and a $ref to the model’s JSON Schema, which is what the interactive documentation renders (see OpenAPI & Interactive Docs):

{"content": {"application/json": {"schema": {"$ref": "#/components/schemas/BookIn"}}}, "required": true}
Table 2. How FastAPI treats a body
Step What happens

1

The Content-Type is checked: a JSON media type (application/json, or any +json type) makes FastAPI parse the body as JSON. Without it the raw text is not decoded (see Pitfalls).

2

The parsed JSON is validated against the model; every problem is collected, not just the first.

3

On success the handler receives the model instance; on failure a 422 is returned and the handler is not called.

4

The schema is added to /openapi.json.

Body, Path and Query Together

FastAPI decides where each parameter comes from by looking at the declaration:

  • a name that appears in the path template ({book_id}) is a path parameter;

  • a parameter of a singular type (int, str, bool, date, …​) that is not in the path is a query parameter;

  • a parameter whose type is a Pydantic model (or another structure Pydantic treats as an object) is the body.

from typing import Annotated
from fastapi import FastAPI, Path, Query
from fastapi.testclient import TestClient
from pydantic import BaseModel

app = FastAPI()


class BookUpdate(BaseModel):
    title: str
    price: float


@app.put("/books/{book_id}")
def replace_book(
    book_id: Annotated[int, Path(ge=1)],          # in the path template -> path
    book: BookUpdate,                              # Pydantic model -> body
    notify: bool = False,                          # simple type -> query
    reason: Annotated[str | None, Query(max_length=50)] = None,
) -> dict:
    return {"book_id": book_id, **book.model_dump(), "notify": notify, "reason": reason}


c = TestClient(app)
print(c.put("/books/7?notify=true&reason=typo", json={"title": "Dune", "price": 12}).json())
op = c.get("/openapi.json").json()["paths"]["/books/{book_id}"]["put"]
print([(p["name"], p["in"], p["required"]) for p in op["parameters"]], list(op["requestBody"]["content"]))
curl -X PUT "http://127.0.0.1:8000/books/7?notify=true&reason=typo" \
  -H "Content-Type: application/json" \
  -d '{"title": "Dune", "price": 12}'
{"book_id": 7, "title": "Dune", "price": 12.0, "notify": true, "reason": "typo"}

The generated operation lists book_id as a required path parameter and notify and reason as optional query parameters, with a single application/json request body. Constraints on the path and query parameters (Path(ge=1), Query(max_length=50)) are covered in Path Parameters and Query Parameters. Headers, cookies and form fields are further sources selected with Header(), Cookie() and Form().

Multiple Body Parameters, Body() and embed

When the handler declares two or more body parameters, FastAPI expects a JSON object whose keys are the parameter names:

from typing import Annotated
from fastapi import Body, FastAPI
from fastapi.testclient import TestClient
from pydantic import BaseModel

app = FastAPI()


class Book(BaseModel):
    title: str


class Author(BaseModel):
    name: str


@app.post("/catalog")
def add_pair(book: Book, author: Author) -> dict:
    return {"book": book, "author": author}


@app.post("/catalog/ranked")
def add_ranked(book: Book, author: Author, rank: Annotated[int, Body(ge=1)]) -> dict:
    return {"book": book, "author": author, "rank": rank}


@app.post("/books/embedded")
def embedded(book: Annotated[Book, Body(embed=True)]) -> dict:
    return {"book": book}


@app.post("/books/plain")
def plain(book: Book) -> dict:
    return {"book": book}


c = TestClient(app)
r = c.post("/catalog", json={"book": {"title": "Dune"}, "author": {"name": "Frank Herbert"}})
print(r.status_code, r.json())
r = c.post("/catalog", json={"title": "Dune"})
print(r.status_code, r.json()["detail"])
r = c.post("/catalog/ranked", json={"book": {"title": "Dune"}, "author": {"name": "F"}, "rank": 1})
print(r.status_code, r.json())
print(c.post("/books/embedded", json={"book": {"title": "Dune"}}).json())
print(c.post("/books/embedded", json={"title": "Dune"}).status_code)
print(c.post("/books/plain", json={"title": "Dune"}).json())
print(c.post("/books/plain", json={"book": {"title": "Dune"}}).status_code)

A request for /catalog must look like this; each model is validated under its own key:

{"book": {"title": "Dune"}, "author": {"name": "Frank Herbert"}}

Sending the flat {"title": "Dune"} instead fails with two missing errors, located at ["body", "book"] and ["body", "author"].

Body() has two jobs:

  • Singular values in the body. A plain int would be read as a query parameter. Annotating it with Body() (here Annotated[int, Body(ge=1)]) makes it a key of the body object next to the models, as rank is in /catalog/ranked. Body() accepts the same validation keywords as Query() and Path() (gt, ge, lt, le, min_length, max_length, pattern) plus title, description, examples, openapi_examples, media_type and deprecated.

  • embed=True. With a single body parameter FastAPI expects the model’s fields at the top level (/books/plain accepts {"title": "Dune"}). Body(embed=True) makes it expect the model nested under the parameter name (/books/embedded accepts {"book": {"title": "Dune"}} and rejects the flat form with a 422). Use it when the API shape is {"book": {…​}}, or when you expect to add further body parameters later without breaking the shape of existing requests.

Table 3. Which JSON shape does the endpoint expect?
Body parameters Expected JSON

one model

the model’s fields at the top level

one model with Body(embed=True)

{"param_name": {…​fields…​}}

several models

{"first": {…​}, "second": {…​}}

a model plus Body() singular values

the model’s key plus the singular keys, side by side

a single Annotated[list[int], Body()]

a JSON array at the top level

Validation and Metadata with Field

Constraints that belong to the data model go in pydantic.Field, either as the default (x: int = Field(ge=1)) or, as recommended throughout this section, inside Annotated. Inside Annotated, a default is then written with = as for any other parameter, so a field without = …​ is required.

from typing import Annotated
from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import BaseModel, Field

app = FastAPI()


class Review(BaseModel):
    reviewer: Annotated[str, Field(min_length=2, max_length=40, description="Display name", examples=["Ada"])]
    rating: Annotated[int, Field(ge=1, le=5, description="Stars from 1 to 5")]
    text: str | None = Field(default=None, max_length=500, title="Review text")
    helpful_votes: int = Field(default=0, ge=0, json_schema_extra={"readOnly": True})
    code: str = Field(default="AB-1", pattern=r"^[A-Z]{2}-\d$")
    tags: list[str] = Field(default_factory=list, max_length=3)
    score: float = Field(default=1.0, gt=0, lt=10, multiple_of=0.5)


@app.post("/reviews")
def add_review(review: Review) -> Review:
    return review


c = TestClient(app)
print(c.post("/reviews", json={"reviewer": "Ada", "rating": 5}).json())
for body in [{"reviewer": "A", "rating": 5}, {"reviewer": "Ada", "rating": 6}, {"reviewer": "Ada", "rating": 3, "code": "zz"},
             {"reviewer": "Ada", "rating": 3, "tags": list("abcd")}, {"reviewer": "Ada", "rating": 3, "score": 1.2}]:
    e = c.post("/reviews", json=body).json()["detail"][0]
    print(e["type"], e["loc"], e["msg"], e.get("ctx"))
print(c.get("/openapi.json").json()["components"]["schemas"]["Review"]["properties"])

The default review returned {"reviewer": "Ada", "rating": 5, "text": null, "helpful_votes": 0, "code": "AB-1", "tags": [], "score": 1.0}, and each invalid payload produced a precise error:

Table 4. Errors produced by the constraints above
Input type ctx

reviewer: "A"

string_too_short

{"min_length": 2}

rating: 6

less_than_equal

{"le": 5}

code: "zz"

string_pattern_mismatch

\{"pattern": "^[A-Z]{2}-\\d$"}

four tags

too_long

{"field_type": "List", "max_length": 3, "actual_length": 4}

score: 1.2

multiple_of

{"multiple_of": 0.5}

/openapi.json carries the constraints and metadata into the schema: reviewer gets minLength: 2, maxLength: 40, description and examples; rating gets minimum: 1.0 and maximum: 5.0; score gets exclusiveMinimum, exclusiveMaximum and multipleOf; tags gets maxItems: 3; and helpful_votes gets the extra readOnly: true passed through json_schema_extra.

Table 5. Commonly used Field arguments
Argument Effect

default, default_factory

A default value, or a callable producing a fresh one (list, uuid4, datetime.now-style functions). Without either, the field is required.

gt, ge, lt, le, multiple_of

Numeric bounds and divisibility.

min_length, max_length

String length; also the number of items of lists, sets and tuples (the schema uses maxItems).

pattern

A regular expression for strings. regex= is the deprecated old name.

max_digits, decimal_places

Limits for Decimal values.

strict

Disables lax conversion for this field (for example "5" no longer becomes 5).

alias, validation_alias, serialization_alias

Name used in JSON instead of the attribute name (see the Pydantic page).

title, description, examples, deprecated

Metadata copied into the JSON Schema and the interactive documentation.

json_schema_extra

A dict (or callable) merged into the field’s JSON Schema, for custom keys such as readOnly.

exclude

Leaves the field out when the model is serialized.

Field is for fields inside a model. For a body parameter itself (the model or a singular value) use Body(); confusing the two is a common slip, because both accept the same keywords.

Nested Models, Lists, Sets and Dicts

Models can use other models as field types, and any of Pydantic’s collections, to any depth. Validation recurses, and errors report the full path, including list indexes.

from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import BaseModel, HttpUrl

app = FastAPI()


class Author(BaseModel):
    name: str
    website: HttpUrl | None = None


class Edition(BaseModel):
    year: int
    publisher: str


class BookIn(BaseModel):
    title: str
    authors: list[Author]                       # list of nested models
    genres: set[str] = set()                    # duplicates are dropped
    editions: list[Edition] = []
    ratings_by_site: dict[str, float] = {}      # keys and values are validated
    cover_urls: list[HttpUrl] = []


@app.post("/books")
def create(book: BookIn) -> BookIn:
    return book


@app.post("/ratings")
def ratings(weights: dict[int, float]) -> dict:   # body is a bare JSON object
    return {"keys_are": type(next(iter(weights))).__name__, "total": sum(weights.values())}


@app.post("/tags")
def tags(names: list[str]) -> dict:               # body is a bare JSON array
    return {"count": len(names)}


c = TestClient(app)
body = {"title": "Good Omens", "authors": [{"name": "Terry Pratchett", "website": "https://www.terrypratchettbooks.com"}, {"name": "Neil Gaiman"}],
        "genres": ["fantasy", "comedy", "fantasy"], "editions": [{"year": 1990, "publisher": "Gollancz"}],
        "ratings_by_site": {"goodreads": 4.25}, "cover_urls": ["https://example.com/c.jpg"]}
r = c.post("/books", json=body)
print(r.status_code, r.json())
bad = dict(body, authors=[{"name": "T", "website": "not a url"}], editions=[{"year": "x", "publisher": "G"}])
print([(e["loc"], e["type"]) for e in c.post("/books", json=bad).json()["detail"]])
print(c.post("/ratings", json={"1": 2.5, "2": 3}).json())
print(c.post("/ratings", json={"a": 2.5}).json()["detail"][0]["loc"])
print(c.post("/tags", json=["a", "b"]).json())
print(c.post("/tags", json={"a": "b"}).json()["detail"][0]["type"])
schema = c.get("/openapi.json").json()["components"]["schemas"]
print(schema["BookIn"]["properties"]["authors"], schema["BookIn"]["properties"]["genres"])
print(schema["Author"]["properties"]["website"])
Table 6. What each declaration does
Declaration Behaviour (observed in the run)

authors: list[Author]

A JSON array of objects; each element is validated as an Author. OpenAPI: type: array with items pointing to the Author schema.

genres: set[str]

A JSON array in which duplicates are dropped (["fantasy", "comedy", "fantasy"] became two items). OpenAPI: uniqueItems: true.

ratings_by_site: dict[str, float]

A JSON object whose keys and values are both validated.

HttpUrl

A string that must be a valid HTTP(S) URL; it is normalized (the bare host https://www.terrypratchettbooks.com came back with a trailing /). OpenAPI: format: uri, minLength: 1, maxLength: 2083.

weights: dict[int, float] as the whole body

A bare JSON object; JSON keys are always strings, but they are converted to int, so the handler sees integer keys. A key like "a" is rejected with loc ["body", "a", "[key]"].

names: list[str] as the whole body

A bare JSON array; anything else is rejected with list_type.

Invalid nested data reports the exact location. The two broken values in the example (a malformed URL and a non-numeric year) produced ["body", "authors", 0, "website"] with url_parsing and ["body", "editions", 0, "year"] with int_parsing, both in a single response.

Keep nesting shallow where you can: every level is another schema in OpenAPI and another place for a client to get the shape wrong. Models used more than once (like Author) appear once under components/schemas and are referenced.

Extra Data Types

JSON has only strings, numbers, booleans, arrays, objects and null. Pydantic parses richer Python types from those, and FastAPI serializes them back:

from datetime import date, datetime, time, timedelta
from decimal import Decimal
from typing import Annotated
from uuid import UUID, uuid4

from fastapi import Body, FastAPI
from fastapi.testclient import TestClient
from pydantic import BaseModel, Field

app = FastAPI()


class Loan(BaseModel):
    loan_id: UUID = Field(default_factory=uuid4)
    borrowed_at: datetime
    due: date
    reminder_time: time | None = None
    loan_period: timedelta = timedelta(days=14)
    deposit: Decimal = Field(max_digits=6, decimal_places=2)
    tags: frozenset[str] = frozenset()
    checksum: bytes | None = None


@app.post("/loans")
def create_loan(loan: Loan) -> dict:
    return {
        "types": {k: type(v).__name__ for k, v in loan.model_dump().items()},
        "echo": loan,
    }


@app.put("/loans/{loan_id}/extend")
def extend(loan_id: UUID, extra: Annotated[timedelta, Body()]) -> dict:
    return {"loan_id": loan_id, "extra": extra, "seconds": extra.total_seconds()}


c = TestClient(app)
r = c.post("/loans", json={"borrowed_at": "2026-10-02T09:30:00Z", "due": "2026-10-16", "reminder_time": "08:00",
                           "loan_period": "P14D", "deposit": "12.50", "tags": ["a", "b", "a"], "checksum": "abc"})
print(r.status_code, r.json())
print(c.post("/loans", json={"borrowed_at": "2026-10-02T09:30:00Z", "due": "2026-10-16", "deposit": "1234567.1"}).json()["detail"][0]["type"])
print(c.post("/loans", json={"borrowed_at": "2026-10-02T09:30:00", "due": "2026-10-16", "loan_period": 3600, "deposit": 1}).json()["echo"]["loan_period"])
print(c.put("/loans/6f4c1b7e-4d5f-4b6e-9b8a-1c2d3e4f5a6b/extend", json="P1DT12H").json())
print(c.put("/loans/nope/extend", json="P1D").json()["detail"][0]["type"])
s = c.get("/openapi.json").json()["components"]["schemas"]["Loan"]["properties"]
for k in ("loan_id", "borrowed_at", "due", "loan_period", "deposit", "tags", "checksum"):
    print(k, s[k])
Table 7. Python types and their JSON representation (observed)
Python type In JSON (request) In OpenAPI

UUID

string "6f4c1b7e-4d5f-4b6e-9b8a-1c2d3e4f5a6b"

type: string, format: uuid

datetime

ISO 8601 string, with or without offset ("2026-10-02T09:30:00Z")

format: date-time

date

"2026-10-16"

format: date

time

"08:00"

format: time

timedelta

ISO 8601 duration ("P14D", "PT1H") or a number of seconds (3600 became PT1H)

format: duration

Decimal

a number or a string ("12.50"); serialized back as a string

number or numeric-string pattern

frozenset / set

an array; duplicates removed

uniqueItems: true

bytes

a UTF-8 string by default; see JSON with Binary Data: Base64 bytes for binary data

contentMediaType: application/octet-stream

Practical consequences:

  • The handler receives real UUID, datetime, date, time, timedelta, Decimal and frozenset objects, so date arithmetic and Decimal maths need no manual parsing. The first request above shows the types of the dumped model: UUID, datetime, date, time, timedelta, Decimal, frozenset and bytes.

  • Use Decimal for money so the response keeps exact digits ("12.50"); float would have given 12.5. max_digits=6, decimal_places=2 rejected "1234567.1" with decimal_max_digits.

  • A bad UUID produces uuid_parsing with a clear loc (for example ["path", "loan_id"]).

  • Singular values of these types can be body values too: Annotated[timedelta, Body()] in the second endpoint accepted the JSON string "P1DT12H" (129600 seconds).

  • Prefer timezone-aware datetime values (Z or an offset) at the API boundary and store UTC; a naive "2026-10-02T09:30:00" is accepted but carries no zone.

  • Other useful types in pydantic itself: EmailStr (needs email-validator, installed with fastapi[standard]), SecretStr, AnyUrl, IPvAnyAddress, Path-like types and Json. More specialised ones (phone numbers, colours, countries) come from the pydantic-extra-types package, covered in Pydantic v2 Essentials.

Declaring Examples

Examples appear in the generated schema and in the "Try it out" panel of the documentation. There are three levels, from most generic to most specific.

from typing import Annotated
from fastapi import Body, FastAPI
from fastapi.testclient import TestClient
from pydantic import BaseModel, ConfigDict, Field

app = FastAPI()


class Book(BaseModel):
    title: str
    price: float
    model_config = ConfigDict(json_schema_extra={"examples": [{"title": "Dune", "price": 9.99}]})


class Author(BaseModel):
    name: str = Field(examples=["Ursula K. Le Guin"])
    born: int | None = Field(default=None, examples=[1929, 1930])


class Review(BaseModel):
    rating: int
    text: str


@app.post("/books")
def add_book(book: Book) -> Book:
    return book


@app.post("/authors")
def add_author(author: Author) -> Author:
    return author


@app.post("/books/{book_id}/reviews")
def add_review(
    book_id: int,
    review: Annotated[
        Review,
        Body(
            openapi_examples={
                "normal": {"summary": "A normal review", "description": "Rating and a comment.", "value": {"rating": 4, "text": "Loved it"}},
                "rating_only": {"summary": "Rating only", "value": {"rating": 5, "text": ""}},
                "invalid": {"summary": "Rejected: rating is not a number", "value": {"rating": "five", "text": "?"}},
            },
        ),
    ],
) -> Review:
    return review


c = TestClient(app)
spec = c.get("/openapi.json").json()
print(spec["components"]["schemas"]["Book"].get("examples"))
print(spec["components"]["schemas"]["Author"]["properties"])
rb = spec["paths"]["/books/{book_id}/reviews"]["post"]["requestBody"]["content"]["application/json"]
print(list(rb), list(rb["examples"]))
print(rb["examples"]["normal"])
print(c.post("/books/1/reviews", json=rb["examples"]["normal"]["value"]).json())
print(c.post("/books/1/reviews", json=rb["examples"]["invalid"]["value"]).status_code)
Table 8. Where to declare examples
Mechanism Scope Result in /openapi.json

model_config = ConfigDict(json_schema_extra={"examples": […​]})

the whole model, shown wherever the model is used

examples: [{"price": 9.99, "title": "Dune"}] on the Book schema

Field(examples=[…​])

one field

examples: ["Ursula K. Le Guin"] (and [1929, 1930]) on the property

Body(examples=[…​]), Query(…​), Path(…​), Header(…​), Cookie(…​), Form(…​), File(…​)

one parameter for one endpoint

examples inside the parameter’s schema

Body(openapi_examples={…​})

one body for one endpoint, with names and descriptions

a named examples map next to schema in the media type object

openapi_examples is a dict from an id to an object with summary, description and value (also externalValue in the OpenAPI specification). The Swagger UI shows them as a drop-down, and the example above produced ["normal", "rating_only", "invalid"] under requestBody.content["application/json"].examples. An example is documentation: FastAPI does not validate it, which is why the third one can intentionally show a rejected payload (it returned 422 when sent). Their format is the OpenAPI Example Object, so each value must be what a client would send.

Notes on versions and older code:

  • OpenAPI 3.1 (used since FastAPI 0.99.0, 2023-06-30) supports a list examples, taken from JSON Schema. The old single example= keyword is deprecated; migrate it to examples=[…​]. openapi_examples was added in 0.103.0 (2023-08-26); older tutorials pass a dict to examples= instead.

  • schema_extra in an inner class Config is the Pydantic v1 way; in v2 it is json_schema_extra in model_config.

  • Examples are for people reading the docs. Seeds and fixtures for tests belong in test code (see Testing).

Dataclasses as Bodies

Python’s standard dataclasses.dataclass works as a body type too: FastAPI hands it to Pydantic, which validates it and creates the instance. A pydantic.dataclasses.dataclass additionally supports Field constraints directly.

from dataclasses import dataclass, field
from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import Field as PField
from pydantic.dataclasses import dataclass as pdataclass

app = FastAPI()


@dataclass
class Publisher:
    name: str
    country: str = "ES"


@dataclass
class BookDC:
    title: str
    pages: int
    publisher: Publisher | None = None
    tags: list[str] = field(default_factory=list)


@pdataclass
class Shelf:
    code: str = PField(min_length=2)
    capacity: int = PField(default=10, gt=0)


@app.post("/books")
def create(book: BookDC) -> BookDC:
    return book


@app.post("/shelves")
def shelf(s: Shelf) -> dict:
    return {"type": f"{type(s).__module__}.{type(s).__name__}", "capacity": s.capacity}


c = TestClient(app)
print(c.post("/books", json={"title": "Dune", "pages": "412", "publisher": {"name": "Chilton"}}).json())
print(c.post("/books", json={"title": "Dune"}).json()["detail"])
print(c.post("/shelves", json={"code": "A1"}).json(), c.post("/shelves", json={"code": "A", "capacity": 0}).status_code)
print(c.get("/openapi.json").json()["components"]["schemas"]["BookDC"])

The call with "pages": "412" returned pages as the integer 412 and a nested publisher with its default country, so conversion and nesting behave as for BaseModel; the missing pages produced the same missing error. The BookDC schema lists title and pages as required and the nested Publisher as a $ref, exactly like a model. Shelf (a Pydantic dataclass) rejected {"code": "A", "capacity": 0} with 422.

Choose BaseModel by default: it has model_dump, model_validate, model_copy, model_config and the best tooling. Choose dataclasses when the same classes must be shared with code that knows nothing about Pydantic, or when you migrate an existing code base. Dataclasses can also be used in response_model (see Response Models & Return Types).

Updating Data: PUT Replaces, PATCH Merges

The two HTTP verbs have different contracts, and a body model has to be treated accordingly.

  • PUT sends the complete representation; whatever is not sent returns to its default. Use the same model as for creation.

  • PATCH sends only the changes. Use a model where every field is optional, and apply only the fields the client actually sent.

The tool for "what did the client send?" is Pydantic’s model_fields_set, used by model_dump(exclude_unset=True): it separates a field the client omitted from one it sent explicitly as null.

from typing import Annotated
from fastapi import Body, FastAPI, HTTPException
from fastapi.encoders import jsonable_encoder
from fastapi.testclient import TestClient
from pydantic import BaseModel

app = FastAPI()


class Book(BaseModel):
    title: str
    price: float
    pages: int | None = None
    in_stock: bool = True


class BookPatch(BaseModel):
    title: str | None = None
    price: float | None = None
    pages: int | None = None
    in_stock: bool | None = None


DB: dict[int, Book] = {1: Book(title="Dune", price=9.99, pages=412)}


@app.put("/books/{book_id}")
def replace(book_id: int, book: Book) -> Book:
    if book_id not in DB:
        raise HTTPException(404, "Book not found")
    DB[book_id] = book            # full replacement: omitted optional fields fall back to defaults
    return book


@app.patch("/books/{book_id}")
def patch(book_id: int, changes: BookPatch) -> Book:
    stored = DB.get(book_id)
    if stored is None:
        raise HTTPException(404, "Book not found")
    data = changes.model_dump(exclude_unset=True)       # only what the client actually sent
    DB[book_id] = stored.model_copy(update=data)
    return DB[book_id]


@app.get("/encoded/{book_id}")
def encoded(book_id: int) -> dict:
    return jsonable_encoder(DB[book_id])


c = TestClient(app)
print(c.patch("/books/1", json={"price": 7.5}).json())
print(c.patch("/books/1", json={"pages": None}).json())      # explicit null is "set", so it clears pages
print(c.put("/books/1", json={"title": "Dune", "price": 5}).json())   # pages gone, in_stock back to default
print(BookPatch(price=1).model_fields_set, BookPatch().model_dump(), BookPatch(price=1).model_dump(exclude_unset=True))
print(c.patch("/books/9", json={"price": 1}).status_code)
# pitfall: model_copy(update=) does not validate
b = Book(title="x", price=1).model_copy(update={"price": "not a number"})
print(repr(b.price))
print(Book.model_validate(Book(title="x", price=1).model_dump() | {"price": "2.5"}).price)
try:
    Book.model_validate(Book(title="x", price=1).model_dump() | {"price": "bad"})
except Exception as e:
    print(type(e).__name__)
print(c.get("/encoded/1").json())

The printed lines, in order:

{'title': 'Dune', 'price': 7.5, 'pages': 412, 'in_stock': True}      # PATCH {"price": 7.5}
{'title': 'Dune', 'price': 7.5, 'pages': None, 'in_stock': True}     # PATCH {"pages": null} clears pages
{'title': 'Dune', 'price': 5.0, 'pages': None, 'in_stock': True}     # PUT {"title": "Dune", "price": 5}

The PATCH that sent only price left pages alone, because exclude_unset=True dropped every field the client did not send. The PATCH that sent "pages": null did clear the field, because an explicit null counts as set. The PUT without pages and in_stock reset them to their defaults, which is what replacement means.

Table 9. Tools for applying a partial update
Tool Notes

model_dump(exclude_unset=True)

Only fields in model_fields_set. Use for PATCH. Compare with exclude_defaults=True (omits values equal to the default, even if sent) and exclude_none=True (omits explicit null too, so it cannot clear a field).

stored.model_copy(update=data)

Returns a new model with the fields replaced. It does not validate the new values: model_copy(update={"price": "not a number"}) happily stored the string 'not a number'. Safe here only because the patch model already validated the data.

Book.model_validate(stored.model_dump() | data)

Merge and re-validate; raises ValidationError for an invalid combination (for example a cross-field rule). Prefer this when the full model has `model_validator`s or when the patch types are looser than the stored model’s.

jsonable_encoder(model)

Converts a model to JSON-compatible data (datetimes to strings, Decimal and so on) for storing in a JSON-only database or sending to a client; see the JSON Compatible Encoder page.

A generated "all optional" patch model duplicates the fields of the full model, which is easy to forget when a field is added. Keeping the models together and testing both is the usual guard; SQL Databases with SQLModel shows the same create/update model split with a database. Status codes and the HTTPException used for the missing book are in Status Codes & Error Handling.

JSON with Binary Data: Base64 bytes

JSON cannot carry raw bytes. If you must include binary data in a JSON body (a small cover thumbnail, a signature), encode it as base64 and tell Pydantic to do so with val_json_bytes (validation of incoming JSON) and ser_json_bytes (serialization of outgoing JSON) in model_config:

import base64
from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import BaseModel

app = FastAPI()


class CoverIn(BaseModel):
    filename: str
    data: bytes
    model_config = {"val_json_bytes": "base64"}


class CoverOut(BaseModel):
    filename: str
    data: bytes
    model_config = {"ser_json_bytes": "base64"}


class CoverBoth(BaseModel):
    filename: str
    data: bytes
    model_config = {"val_json_bytes": "base64", "ser_json_bytes": "base64"}


@app.post("/covers")
def upload(cover: CoverIn) -> dict:
    return {"filename": cover.filename, "size": len(cover.data), "starts_with": cover.data[:4].decode("latin-1")}


@app.get("/covers/sample")
def sample() -> CoverOut:
    return CoverOut(filename="a.bin", data=b"\x89PNG\r\n")


@app.post("/covers/echo")
def echo(cover: CoverBoth) -> CoverBoth:
    return cover


c = TestClient(app)
raw = b"\x89PNG\r\n\x1a\n"
enc = base64.b64encode(raw).decode()
print(enc)
print(c.post("/covers", json={"filename": "a.png", "data": enc}).json())
print(c.get("/covers/sample").json())
print(c.post("/covers/echo", json={"filename": "a.png", "data": enc}).json())
print(c.post("/covers", json={"filename": "a.png", "data": "***"}).json()["detail"][0]["type"])

The run printed iVBORw0KGgo= for the encoded PNG signature. POST /covers decoded it into 8 bytes, GET /covers/sample returned {"filename": "a.bin", "data": "iVBORw0K"}, the round trip returned the same base64 text, and a payload that is not base64 ("*") failed with bytes_invalid_encoding.

Base64 makes the payload about one third larger and forces the whole file into memory and into one JSON document. Use it only when the data really has to live inside JSON; for files, multipart uploads with UploadFile are the better tool (Forms & File Uploads).

Pitfalls

import json
from typing import Annotated, Any
from fastapi import Body, FastAPI
from fastapi.testclient import TestClient
from pydantic import BaseModel, ConfigDict, Field

app = FastAPI()


class Book(BaseModel):
    model_config = ConfigDict(extra="forbid")
    title: str
    tags: list[str] = []          # safe: Pydantic copies defaults per instance
    series_no: int | None = Field(default=None, alias="seriesNo")


@app.post("/books")
def create(book: Book) -> dict:
    return book.model_dump(by_alias=True)


@app.post("/free")
def free(payload: Annotated[dict[str, Any], Body()]) -> dict:
    return {"keys": sorted(payload)}


@app.post("/numbers")
def numbers(values: Annotated[list[int], Body(min_length=1, max_length=3)]) -> dict:
    return {"sum": sum(values)}


c = TestClient(app)
print(c.post("/books", json={"title": "Dune", "color": "red"}).json()["detail"][0])
print(c.post("/books", json={"title": "Dune", "seriesNo": 1}).json())
print(c.post("/books", json={"title": "Dune", "series_no": 1}).status_code)
# Content-Type: missing header on a JSON body (strict_content_type is on by default since 0.132.0)
r = c.post("/books", content=json.dumps({"title": "Dune"}))
print(r.status_code, r.json())
r = c.post("/books", content=json.dumps({"title": "Dune"}), headers={"Content-Type": "application/json; charset=utf-8"})
print(r.status_code)
r = c.post("/books", content=json.dumps({"title": "Dune"}), headers={"Content-Type": "application/vnd.api+json"})
print(r.status_code)
print(c.post("/free", json={"a": 1, "b": [1]}).json())
print(c.post("/numbers", json=[1, 2, 3]).json(), c.post("/numbers", json=[]).json()["detail"][0]["type"])
print(c.request("GET", "/books", json={"title": "x"}).status_code)
print(c.get("/openapi.json").json()["paths"]["/books"].keys())
  • Body sent without a JSON Content-Type. Since FastAPI 0.132.0 (2026-02-23) the content type is checked strictly. A request whose body is valid JSON but arrives with no Content-Type header (the call made with content= and no headers in the example) is treated as plain text and gets 422 with model_attributes_type and the raw text as input; application/json; charset=utf-8 and application/vnd.api+json both worked. Tell clients to set the header (curl with -d and no -H sends application/x-www-form-urlencoded, which is a different body type). Passing strict_content_type=False to FastAPI(…​) restores the old lenient behaviour.

  • Typos silently ignored. The default is extra="ignore", so "titel" is dropped and the required title is reported missing, or an optional misspelled field is silently lost. For write endpoints consider model_config = ConfigDict(extra="forbid"): the first call returned extra_forbidden with loc ["body", "color"].

  • Aliases. With Field(alias="seriesNo") the client must send seriesNo; series_no returned 422. Use model_dump(by_alias=True) to produce the same names again. Details in the Pydantic page.

  • A body on GET. The specification does not define semantics for a GET body; many proxies and clients drop it and OpenAPI tooling may ignore it. Send search criteria as query parameters, or as a query parameter model (see Query Parameters), or use POST for a search that needs a body.

  • Bodies are read fully before the handler runs. A huge JSON document is parsed in memory. Limit the size at the server or proxy (see Middleware, CORS & Proxies) and prefer streaming or file uploads for large data.

  • Mutable defaults are safe in Pydantic (tags: list[str] = [] is copied per instance, as the run showed with a fresh []), unlike in ordinary classes and function signatures, but default_factory states the intent more clearly.

  • Optional[X] without a default is required. Write x: X | None = None for "may be omitted".

  • model_copy(update=…​) skips validation (see Updating Data: PUT Replaces, PATCH Merges), and model_dump() returns Python objects (such as datetime), not JSON-safe values: use model_dump(mode="json") or jsonable_encoder before storing.

  • A bare dict or Any body (Annotated[dict[str, Any], Body()]) accepts anything and documents nothing; use it only for genuinely free-form data. The run accepted {"a": 1, "b": [1]} and reported only the keys.

  • List bodies need a Body() or a top-level type. Annotated[list[int], Body(min_length=1, max_length=3)] accepts a JSON array; an empty array failed with too_short.

Where Older Guides Are Outdated, and What Changed Recently

  • Pydantic v1 code. v1 support was removed in FastAPI 0.126.0 (2025-12-20) and pydantic.v1 in 0.128.0 (2025-12-27). Replace .dict() with model_dump(), .copy(update=…​) with model_copy(update=…​), .json() with model_dump_json(), class Config: with model_config, schema_extra with json_schema_extra, min_items / max_items with min_length / max_length, regex= with pattern=, and Field(…​) for "required" with simply no default. The complete mapping is in Pydantic v2 Essentials.

  • Error format. v2 error payloads use short type values (missing, int_parsing, string_too_short) and input; v1 used value_error.missing and similar.

  • Optional[X] and default markers. Use X | None and Annotated[X, Body(…​)] (0.95.0 and later) instead of item: Item = Body(…​, embed=True).

  • Examples. Body(example=…​) is deprecated; use examples=[…​], and openapi_examples (0.103.0, 2023-08-26) for named, documented examples. Older tutorials use a plain dict for examples=.

  • Models for Query, Header, Cookie and Form. Since 0.115.0 (2024-09-17) and 0.113.0 (2024-09-05) the same BaseModel technique used for bodies also works for other parameter sources; see Query Parameters, Headers & Cookies and Forms & File Uploads.

  • Strict content type. 0.132.0 (2026-02-23) made the JSON Content-Type requirement strict (see Pitfalls).

  • Base64 bytes. Documented in the advanced guide as val_json_bytes / ser_json_bytes.

  • Rust-side JSON serialization. Since 0.130.0 (2026-02-22) responses with a return type or response_model are serialized directly by Pydantic, which is faster; see Response Models & Return Types.