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"])
Source: FastAPI: Request Body
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:
| Request | type and loc |
msg |
|---|---|---|
missing required field |
|
Field required |
wrong type for a field |
|
Input should be a valid integer, unable to parse string as an integer |
malformed JSON text |
|
JSON decode error |
JSON array instead of object |
|
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}
| Step | What happens |
|---|---|
1 |
The |
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 |
4 |
The schema is added to |
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"]))
Source: FastAPI: Request Body
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
intwould be read as a query parameter. Annotating it withBody()(hereAnnotated[int, Body(ge=1)]) makes it a key of the body object next to the models, asrankis in/catalog/ranked.Body()accepts the same validation keywords asQuery()andPath()(gt,ge,lt,le,min_length,max_length,pattern) plustitle,description,examples,openapi_examples,media_typeanddeprecated. -
embed=True. With a single body parameter FastAPI expects the model’s fields at the top level (/books/plainaccepts{"title": "Dune"}).Body(embed=True)makes it expect the model nested under the parameter name (/books/embeddedaccepts{"book": {"title": "Dune"}}and rejects the flat form with a422). 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.
| Body parameters | Expected JSON |
|---|---|
one model |
the model’s fields at the top level |
one model with |
|
several models |
|
a model plus |
the model’s key plus the singular keys, side by side |
a single |
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"])
Source: FastAPI: Body - Fields
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:
| Input | type |
ctx |
|---|---|---|
|
|
|
|
|
|
|
|
|
four tags |
|
|
|
|
|
/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.
| Argument | Effect |
|---|---|
|
A default value, or a callable producing a fresh one ( |
|
Numeric bounds and divisibility. |
|
String length; also the number of items of lists, sets and tuples (the schema uses |
|
A regular expression for strings. |
|
Limits for |
|
Disables lax conversion for this field (for example |
|
Name used in JSON instead of the attribute name (see the Pydantic page). |
|
Metadata copied into the JSON Schema and the interactive documentation. |
|
A dict (or callable) merged into the field’s JSON Schema, for custom keys such as |
|
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"])
Source: FastAPI: Body - Nested Models
| Declaration | Behaviour (observed in the run) |
|---|---|
|
A JSON array of objects; each element is validated as an |
|
A JSON array in which duplicates are dropped ( |
|
A JSON object whose keys and values are both validated. |
|
A string that must be a valid HTTP(S) URL; it is normalized (the bare host |
|
A bare JSON object; JSON keys are always strings, but they are converted to |
|
A bare JSON array; anything else is rejected with |
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])
Source: FastAPI: Extra Data Types
| Python type | In JSON (request) | In OpenAPI |
|---|---|---|
|
string |
|
|
ISO 8601 string, with or without offset ( |
|
|
|
|
|
|
|
|
ISO 8601 duration ( |
|
|
a number or a string ( |
number or numeric-string pattern |
|
an array; duplicates removed |
|
|
a UTF-8 string by default; see JSON with Binary Data: Base64 |
|
Practical consequences:
-
The handler receives real
UUID,datetime,date,time,timedelta,Decimalandfrozensetobjects, so date arithmetic andDecimalmaths need no manual parsing. The first request above shows the types of the dumped model:UUID,datetime,date,time,timedelta,Decimal,frozensetandbytes. -
Use
Decimalfor money so the response keeps exact digits ("12.50");floatwould have given12.5.max_digits=6, decimal_places=2rejected"1234567.1"withdecimal_max_digits. -
A bad UUID produces
uuid_parsingwith a clearloc(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
datetimevalues (Zor 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
pydanticitself:EmailStr(needsemail-validator, installed withfastapi[standard]),SecretStr,AnyUrl,IPvAnyAddress,Path-like types andJson. More specialised ones (phone numbers, colours, countries) come from thepydantic-extra-typespackage, 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)
| Mechanism | Scope | Result in /openapi.json |
|---|---|---|
|
the whole model, shown wherever the model is used |
|
|
one field |
|
|
one parameter for one endpoint |
|
|
one body for one endpoint, with names and descriptions |
a named |
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 singleexample=keyword is deprecated; migrate it toexamples=[…].openapi_exampleswas added in 0.103.0 (2023-08-26); older tutorials pass a dict toexamples=instead. -
schema_extrain an innerclass Configis the Pydantic v1 way; in v2 it isjson_schema_extrainmodel_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"])
Source: FastAPI: Using Dataclasses
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.
-
PUTsends the complete representation; whatever is not sent returns to its default. Use the same model as for creation. -
PATCHsends 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())
Source: FastAPI: Body - Updates
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.
| Tool | Notes |
|---|---|
|
Only fields in |
|
Returns a new model with the fields replaced. It does not validate the new values: |
|
Merge and re-validate; raises |
|
Converts a model to JSON-compatible data (datetimes to strings, |
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())
Source: FastAPI: Request Body
-
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 noContent-Typeheader (the call made withcontent=and no headers in the example) is treated as plain text and gets422withmodel_attributes_typeand the raw text asinput;application/json; charset=utf-8andapplication/vnd.api+jsonboth worked. Tell clients to set the header (curlwith-dand no-Hsendsapplication/x-www-form-urlencoded, which is a different body type). Passingstrict_content_type=FalsetoFastAPI(…)restores the old lenient behaviour. -
Typos silently ignored. The default is
extra="ignore", so"titel"is dropped and the requiredtitleis reported missing, or an optional misspelled field is silently lost. For write endpoints considermodel_config = ConfigDict(extra="forbid"): the first call returnedextra_forbiddenwithloc["body", "color"]. -
Aliases. With
Field(alias="seriesNo")the client must sendseriesNo;series_noreturned422. Usemodel_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 aGETbody; 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 usePOSTfor 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, butdefault_factorystates the intent more clearly. -
Optional[X]without a default is required. Writex: X | None = Nonefor "may be omitted". -
model_copy(update=…)skips validation (see Updating Data: PUT Replaces, PATCH Merges), andmodel_dump()returns Python objects (such asdatetime), not JSON-safe values: usemodel_dump(mode="json")orjsonable_encoderbefore storing. -
A bare
dictorAnybody (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 withtoo_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.v1in 0.128.0 (2025-12-27). Replace.dict()withmodel_dump(),.copy(update=…)withmodel_copy(update=…),.json()withmodel_dump_json(),class Config:withmodel_config,schema_extrawithjson_schema_extra,min_items/max_itemswithmin_length/max_length,regex=withpattern=, andField(…)for "required" with simply no default. The complete mapping is in Pydantic v2 Essentials. -
Error format. v2 error payloads use short
typevalues (missing,int_parsing,string_too_short) andinput; v1 usedvalue_error.missingand similar. -
Optional[X]and default markers. UseX | NoneandAnnotated[X, Body(…)](0.95.0 and later) instead ofitem: Item = Body(…, embed=True). -
Examples.
Body(example=…)is deprecated; useexamples=[…], andopenapi_examples(0.103.0, 2023-08-26) for named, documented examples. Older tutorials use a plain dict forexamples=. -
Models for Query, Header, Cookie and Form. Since 0.115.0 (2024-09-17) and 0.113.0 (2024-09-05) the same
BaseModeltechnique 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-Typerequirement 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_modelare serialized directly by Pydantic, which is faster; see Response Models & Return Types.