Pydantic v2 Essentials
|
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 (2.13.5, released 2026-08-28) and
Starlette 1.7, checked on 2026-10-02. Every example was executed on Python 3.13 with those versions through
FastAPI’s TestClient (or with plain Pydantic where FastAPI is not involved); the printed output shown after an
example is what that run produced. FastAPI does not validate anything itself: every request parameter, every body and
every response goes through Pydantic, so the quality of your API is largely the quality of your Pydantic models. This
page explains the Pydantic features you meet in FastAPI work, shows the effect each has on the 422 response and on
the OpenAPI document, and ends with the table that maps Pydantic v1 code (the style of most books and tutorials
written before mid-2023) to v2.
What Pydantic Does in FastAPI
FastAPI inspects the signature of a path operation function and, for every parameter, decides where the value comes
from (path, query, header, cookie, body, form) and what type it must have. The second half is delegated entirely to
Pydantic, whose validation engine (pydantic-core) is written in Rust. The division of work is:
| Layer | Responsibility |
|---|---|
Starlette |
Receives the ASGI request, exposes headers, cookies, query string and the raw body, and builds the response. |
FastAPI |
Reads the function signature, extracts each value from the right place of the request, checks the |
Pydantic |
Coerces and validates values against the declared types and constraints, runs your validators, builds the model instance, serializes models back to JSON-compatible data and produces the JSON Schema that FastAPI embeds in OpenAPI. |
Everything on this page therefore applies to all parameter kinds, not only to JSON bodies: Query(), Path(),
Header(), Cookie(), Form() and Body() accept the same constraint arguments as Field(), and a Pydantic model
can describe a group of query parameters, headers, cookies or form fields (see
Query Parameters,
Headers & Cookies and
Forms & File Uploads). The way request bodies are declared,
nested and documented is the subject of Request Body & Models;
this page covers the Pydantic machinery underneath. Pydantic is a separate project with its own version number;
FastAPI 0.142 requires pydantic>=2.9.0 and does not support Pydantic v1 any more (see Migrating from Pydantic v1 to v2).
Models and Fields
A Pydantic model is a class that inherits from BaseModel; each annotated attribute is a field. The annotation is the
validation rule, a default value makes the field optional, and a field without a default is required:
from datetime import date
from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import BaseModel, Field
class Author(BaseModel):
name: str # required
born: date | None = None # optional, may be None
tags: list[str] = Field(default_factory=list) # fresh list per instance
app = FastAPI()
@app.post("/authors")
def create_author(author: Author) -> Author:
return author
c = TestClient(app)
print(c.post("/authors", json={"name": "Ursula", "born": "1929-10-21"}).json())
print(c.post("/authors", json={"born": "1929-10-21"}).json()["detail"][0])
# Models are keyword-only; positional arguments raise TypeError
try:
Author("Ursula")
except TypeError as exc:
print("TypeError:", exc)
a = Author(name="Ursula")
print(a.model_fields_set, a.tags is Author(name="X").tags)
print(Author.model_fields["name"].is_required(), Author.model_fields["born"].is_required())
Source: Pydantic: Models
Output:
{'name': 'Ursula', 'born': '1929-10-21', 'tags': []}
{'type': 'missing', 'loc': ['body', 'name'], 'msg': 'Field required', 'input': {'born': '1929-10-21'}}
TypeError: BaseModel.__init__() takes 1 positional argument but 2 were given
{'name'} False
True False
The points worth noticing:
-
Required versus nullable are independent.
born: date | None = Noneis optional and nullable.name: str | Nonewithout a default is required (the client must send the key, possibly asnull) — this is a behaviour change from v1, see Migrating from Pydantic v1 to v2. -
Mutable defaults are safe. Pydantic copies defaults, so
tags: list[str] = []would not be shared between instances either, butField(default_factory=list)states the intent explicitly and is required for defaults that must be computed per instance (for exampledefault_factory=datetime.now). -
Models are keyword-only.
Author("Ursula")is aTypeError; writeAuthor(name="Ursula"). In FastAPI you rarely construct models by hand, but the rule matters in tests and in code that maps database rows to models. -
model_fields_setremembers what was provided. This is howexclude_unset=Trueand PATCH-style updates work (see Serialization). -
Models validate on construction only. Assigning
a.name = 5later does not validate unless you enablevalidate_assignment(see Model Configuration:model_config).
The type annotations accepted are everything Python’s typing module offers plus Pydantic’s own types: str, int,
float, bool, bytes, Decimal, UUID, date, datetime, time, timedelta, Enum, Literal, list, set,
frozenset, tuple, dict, unions with |, nested models, TypedDict, dataclasses and the specialised types in
pydantic.types (HttpUrl, EmailStr, SecretStr, PositiveInt, AwareDatetime, Json and so on). The meaning of
X | None and Annotated is explained in Python Types & Annotated.
Field Types and Constraints
Field() attaches constraints and metadata to a field. It works as a default (x: int = Field(ge=1)) or, preferably
in FastAPI code, inside Annotated (x: Annotated[int, Field(ge=1)] = 1), which keeps the real default value in the
usual place and lets you reuse the annotated type (see Constraints with Annotated). The arguments of Field() in
Pydantic 2.13, grouped:
| Group | Arguments | Effect |
|---|---|---|
Defaults |
|
The value used when the key is missing. A default is not validated unless |
Numbers |
|
Bounds and shape of |
Sizes |
|
Length of |
Strings |
|
A regular expression the string must match (v1’s |
Strictness |
|
Per-field switch between strict and lax coercion, and permission to accept numbers for |
Unions |
|
|
Names |
|
The external name of the field (see Computed Fields and Aliases). |
Documentation |
|
Metadata copied into the JSON Schema and so into OpenAPI and Swagger UI. |
Behaviour |
|
|
The next example combines the numeric, string and size constraints in one model and shows both the 422 details and
the fragment of the OpenAPI schema that results:
from decimal import Decimal
from typing import Annotated
from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import BaseModel, Field, StringConstraints
Isbn = Annotated[str, StringConstraints(pattern=r"^\d{13}$")]
Rating = Annotated[int, Field(ge=1, le=5, description="Stars, 1 to 5")]
Slug = Annotated[str, StringConstraints(strip_whitespace=True, to_lower=True, min_length=3, max_length=40)]
class Book(BaseModel):
isbn: Isbn
slug: Slug
price: Annotated[Decimal, Field(gt=0, max_digits=6, decimal_places=2)]
pages: Annotated[int, Field(gt=0, multiple_of=1)] = 100
keywords: Annotated[list[str], Field(min_length=1, max_length=5)] = ["new"]
rating: Rating | None = None
app = FastAPI()
@app.post("/books")
def create_book(book: Book) -> Book:
return book
c = TestClient(app)
ok = {"isbn": "9780441172719", "slug": " DUNE ", "price": "9.99"}
print(c.post("/books", json=ok).json())
bad = {"isbn": "12", "slug": "x", "price": "1.234", "keywords": [], "rating": 9}
for e in c.post("/books", json=bad).json()["detail"]:
print(e["loc"], e["type"], e["msg"])
schemas = c.get("/openapi.json").json()["components"]["schemas"]
print(list(schemas))
schema = schemas["Book-Input"]["properties"]
for k in ("isbn", "slug", "price", "rating"):
print(k, schema[k])
Source: Pydantic: Fields
Output:
{'isbn': '9780441172719', 'slug': 'dune', 'price': '9.99', 'pages': 100, 'keywords': ['new'], 'rating': None}
['body', 'isbn'] string_pattern_mismatch String should match pattern '^\d{13}$'
['body', 'slug'] string_too_short String should have at least 3 characters
['body', 'price'] decimal_max_places Decimal input should have no more than 2 decimal places
['body', 'keywords'] too_short List should have at least 1 item after validation, not 0
['body', 'rating'] less_than_equal Input should be less than or equal to 5
['Book-Input', 'Book-Output', 'HTTPValidationError', 'ValidationError']
isbn {'type': 'string', 'pattern': '^\\d{13}$', 'title': 'Isbn'}
slug {'type': 'string', 'maxLength': 40, 'minLength': 3, 'title': 'Slug'}
price {'anyOf': [{'type': 'number', 'exclusiveMinimum': 0.0}, {'type': 'string', 'pattern': '^(?!^[-+.]*$)[+-]?0*(?:\\d{0,4}|(?=[\\d.]{1,7}0*$)\\d{0,4}\\.\\d{0,2}0*$)'}], 'title': 'Price'}
rating {'anyOf': [{'type': 'integer', 'maximum': 5.0, 'minimum': 1.0, 'description': 'Stars, 1 to 5'}, {'type': 'null'}], 'title': 'Rating'}
Reading the output:
-
StringConstraints(strip_whitespace=True, to_lower=True)transformed the value (" DUNE "became"dune") before the length check ran, so constraints are not only checks but can normalise input. -
Every violation is reported at once, each with a
typethat identifies the rule (string_pattern_mismatch,string_too_short,decimal_max_places,too_short,less_than_equal) and a human-readablemsg. -
The constraints surface in OpenAPI as
pattern,minLength,maxLength,minimum/maximumandexclusiveMinimum;descriptionandexamplesappear in Swagger UI next to the field. -
The model appears twice in
components.schemas, asBook-InputandBook-Output. FastAPI generates separate schemas for the request and the response when the two differ; here theDecimalfield is accepted as a number or a numeric string but is serialized as a string. The behaviour and theseparate_input_output_schemas=Falseswitch are covered in Response Models & Return Types.
Constraints with Annotated
Annotated[T, metadata…] attaches Pydantic metadata to a type without changing the type seen by static checkers.
Four kinds of metadata are common:
-
Field(…), for any of the arguments above; -
StringConstraints(…), a bundle of string rules (strip_whitespace,to_lower,to_upper,min_length,max_length,pattern) that also transforms the value; -
annotated-types constraints (
Gt,Ge,Lt,Le,MinLen,MaxLen), used by libraries and by Pydantic internally; -
validator markers (
AfterValidator,BeforeValidator,WrapValidator,PlainValidator), see Validators.
Because metadata travels with the type, define an alias once and reuse it everywhere. The Isbn, Rating and Slug
aliases in the previous example are exactly that, and the same pattern works for query parameters, which is how a team
keeps limit, offset or slug rules identical across routes. Two consequences matter when you migrate from v1:
-
The v1 constrained types (
conint,constr,conlist,confloat) still exist but are discouraged; their arguments were renamed (conlist(int, min_length=1), notmin_items=1). TheAnnotatedspelling replaces them. -
Constraints do not reach inside containers.
isbns: list[str] = Field(pattern=…)is an error in v2, becausepatternapplies to the list, not to its items. Put the constraint on the item type:list[Annotated[str, Field(pattern=…)]](verified in Migrating from Pydantic v1 to v2).
Validators
Constraints cover common rules; validators are Python functions for the rest. There are two decorators and four
Annotated markers:
| Mode | Runs | Receives |
|---|---|---|
|
After Pydantic’s own validation of the field |
The validated value, of the declared type. Use this unless you have a reason not to. |
|
Before Pydantic’s validation |
The raw input, of any type (a |
|
Around Pydantic’s validation |
The raw input and a |
|
Instead of Pydantic’s validation |
The raw input; no coercion or constraint is applied afterwards. Also changes the field’s JSON Schema, which becomes empty unless you add one. |
@field_validator("a", "b", mode=…) validates one or more fields ("" means all). It must sit *above
@classmethod, return the (possibly changed) value and may take a ValidationInfo second argument, whose data
holds the previously validated fields (fields are validated in declaration order, so a validator can only compare
with fields declared before it) and whose field_name names the field. @model_validator(mode="before" | "after" | "wrap")
validates the whole model: before receives the raw input (a classmethod), after receives the finished instance and
must return it, and is the place for rules that span several fields.
from typing import Annotated, Any
from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import (AfterValidator, BaseModel, BeforeValidator, ValidationInfo,
field_validator, model_validator)
def strip_title(v: str) -> str:
return v.strip()
Title = Annotated[str, BeforeValidator(lambda v: v.strip() if isinstance(v, str) else v),
AfterValidator(str.title)]
class Review(BaseModel):
book_id: int
title: Title
stars: int
min_stars_for_blurb: int = 4
blurb: str | None = None
@field_validator("stars")
@classmethod
def stars_in_range(cls, v: int) -> int:
if not 1 <= v <= 5:
raise ValueError("stars must be between 1 and 5")
return v
@field_validator("blurb", mode="before")
@classmethod
def empty_blurb_is_none(cls, v: Any) -> Any:
return None if v == "" else v
@field_validator("title")
@classmethod
def title_not_shouting(cls, v: str, info: ValidationInfo) -> str:
if "spoiler" in v.lower():
raise ValueError(f"{info.field_name} must not contain spoilers")
return v
@model_validator(mode="after")
def blurb_needs_stars(self) -> "Review":
if self.blurb and self.stars < self.min_stars_for_blurb:
raise ValueError("a blurb is only accepted with 4 or 5 stars")
return self
app = FastAPI()
@app.post("/reviews")
def create_review(review: Review) -> Review:
return review
c = TestClient(app)
print(c.post("/reviews", json={"book_id": 1, "title": " a good read ", "stars": 5, "blurb": ""}).json())
r = c.post("/reviews", json={"book_id": 1, "title": "x", "stars": 9})
print(r.status_code, r.json())
r = c.post("/reviews", json={"book_id": 1, "title": "x", "stars": 2, "blurb": "meh"})
print(r.status_code, r.json())
r = c.post("/reviews", json={"book_id": 1, "title": "Spoiler alert", "stars": 3})
print(r.json()["detail"][0])
Source: Pydantic: Validators
Output:
{'book_id': 1, 'title': 'A Good Read', 'stars': 5, 'min_stars_for_blurb': 4, 'blurb': None}
422 {'detail': [{'type': 'value_error', 'loc': ['body', 'stars'], 'msg': 'Value error, stars must be between 1 and 5', 'input': 9, 'ctx': {'error': {}}}]}
422 {'detail': [{'type': 'value_error', 'loc': ['body'], 'msg': 'Value error, a blurb is only accepted with 4 or 5 stars', 'input': {'book_id': 1, 'title': 'x', 'stars': 2, 'blurb': 'meh'}, 'ctx': {'error': {}}}]}
{'type': 'value_error', 'loc': ['body', 'title'], 'msg': 'Value error, title must not contain spoilers', 'input': 'Spoiler alert', 'ctx': {'error': {}}}
Three details in that output deserve attention. First, the Title alias combined a BeforeValidator and an
AfterValidator in Annotated, so the normalisation is reusable and visible in the type. Second, the model-level
error has loc ["body"] (the whole model), whereas field errors have ["body", "stars"]. Third, ctx is
{"error": {}}: Pydantic puts the exception object in the context, and FastAPI’s JSON encoder renders an exception
as an empty object. The useful information is in msg; do not rely on ctx.error.
The modes that change what validation does are best seen side by side:
from typing import Any
from pydantic import BaseModel, ValidationError, ValidatorFunctionWrapHandler, field_validator
class Shelf(BaseModel):
name: str
capacity: int
tags: list[str] = []
# one validator for several fields
@field_validator("name", "capacity", mode="before")
@classmethod
def no_blank(cls, v: Any) -> Any:
if isinstance(v, str) and not v.strip():
raise ValueError("must not be blank")
return v
# wrap: runs around the inner validation, can recover from failure
@field_validator("capacity", mode="wrap")
@classmethod
def clamp(cls, v: Any, handler: ValidatorFunctionWrapHandler) -> int:
try:
return handler(v)
except ValidationError:
return 0
# plain: replaces the built-in validation completely
@field_validator("tags", mode="plain")
@classmethod
def split_tags(cls, v: Any) -> list[str]:
return [t.strip() for t in v.split(",")] if isinstance(v, str) else list(v)
print(Shelf(name="Sci-fi", capacity="12", tags="a, b ,c"))
print(Shelf(name="Sci-fi", capacity="many"))
try:
Shelf(name=" ", capacity=1)
except ValidationError as e:
print(e.errors(include_url=False)[0]["msg"])
Source: Pydantic: Validators
Output:
name='Sci-fi' capacity=12 tags=['a', 'b', 'c']
name='Sci-fi' capacity=0 tags=[]
Value error, must not be blank
Rules for the exceptions a validator may raise:
-
Raise
ValueError(the errortypeisvalue_errorandmsgstarts withValue error, `) or `AssertionError(assertion_error). Asserts are removed when Python runs with-O, so preferValueErrorfor real rules. -
Do not raise
HTTPExceptionfrom a validator: a validator also runs when your own code builds the model, and validators of a request model run before your function does, so the framework must be able to collect the problem into the422response. -
To control the error
type,msgandctxyourself, raisepydantic_core.PydanticCustomError(type, message_template, context).
from typing import Annotated
from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import AfterValidator, BaseModel, ValidationInfo, field_validator
from pydantic_core import PydanticCustomError
def isbn_checksum(v: str) -> str:
digits = [int(c) for c in v]
if sum(d * (1 if i % 2 == 0 else 3) for i, d in enumerate(digits)) % 10 != 0:
raise PydanticCustomError("isbn_checksum", "ISBN-13 check digit is wrong: {isbn}", {"isbn": v})
return v
Isbn13 = Annotated[str, AfterValidator(isbn_checksum)]
class Password(BaseModel):
password: str
confirm: str
@field_validator("confirm")
@classmethod
def same(cls, v: str, info: ValidationInfo) -> str:
assert v == info.data.get("password"), "passwords differ" # AssertionError works too
return v
class Book(BaseModel):
isbn: Isbn13
app = FastAPI()
@app.post("/books")
def book(b: Book) -> Book:
return b
@app.post("/passwords")
def pw(p: Password) -> dict:
return {"ok": True}
c = TestClient(app)
print(c.post("/books", json={"isbn": "9780441172710"}).json()["detail"])
print(c.post("/passwords", json={"password": "a", "confirm": "b"}).json()["detail"])
Source: Pydantic: Validators
Output:
[{'type': 'isbn_checksum', 'loc': ['body', 'isbn'], 'msg': 'ISBN-13 check digit is wrong: 9780441172710', 'input': '9780441172710', 'ctx': {'isbn': '9780441172710'}}]
[{'type': 'assertion_error', 'loc': ['body', 'confirm'], 'msg': 'Assertion failed, passwords differ', 'input': 'b', 'ctx': {'error': {}}}]
Model Configuration: model_config
Behaviour that applies to a whole model is configured with model_config = ConfigDict(…). (The v1 inner
class Config: still works in v2 but emits a deprecation warning; see Migrating from Pydantic v1 to v2.) The options you will actually
use in a FastAPI project:
| Option | Values / default | Effect |
|---|---|---|
|
|
What to do with keys that are not fields: drop them, reject them with an |
|
|
Allows validation from arbitrary objects through attribute access (an ORM row, a dataclass) instead of dicts. This is
v1’s |
|
|
Normalise every |
|
unset |
Length bounds for every |
|
|
Instances become immutable and hashable; assignment raises a |
|
|
Re-validate a field on assignment ( |
|
|
Validate default values too (v1’s |
|
|
Strict mode for every field of the model (see Strict vs Lax Validation). |
|
How aliases are used on input, output and in error locations. |
|
|
|
Store the enum’s value instead of the member. |
|
|
Accept |
|
|
Allow classes Pydantic has no schema for, validated with |
|
|
Whether instances passed in as field values are validated again. |
|
- |
Customise the JSON Schema, hence OpenAPI. |
|
|
Field-name prefixes reserved for Pydantic’s own methods; a clash raises a warning. |
|
- |
Format of timedeltas, bytes and so on in JSON. |
|
- |
Start-up and performance switches. |
|
|
Serialize a subclass instance with its own fields when it is held in a field typed as the parent (see Serialization). |
from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import BaseModel, ConfigDict
class NewBook(BaseModel):
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True, str_min_length=1)
title: str
author: str
class BookRow: # stands in for an ORM object
def __init__(self) -> None:
self.id, self.title, self.author = 7, "Dune", "Frank Herbert"
class BookOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
title: str
class Isbn(BaseModel):
model_config = ConfigDict(frozen=True)
value: str
class Lenient(BaseModel):
model_config = ConfigDict(extra="ignore") # the default
a: int
class Keeper(BaseModel):
model_config = ConfigDict(extra="allow")
a: int
app = FastAPI()
@app.post("/books")
def create(book: NewBook) -> NewBook:
return book
@app.get("/books/7")
def get() -> BookOut:
return BookRow() # type: ignore[return-value] -- validated via from_attributes
c = TestClient(app)
print(c.post("/books", json={"title": " Dune ", "author": "Frank Herbert"}).json())
print(c.post("/books", json={"title": "Dune", "author": "F", "year": 1965}).json()["detail"][0])
print(c.post("/books", json={"title": " ", "author": "F"}).json()["detail"][0]["type"])
print(c.get("/books/7").json())
print(BookOut.model_validate(BookRow()))
i = Isbn(value="1")
try:
i.value = "2"
except Exception as e:
print(type(e).__name__, e.errors()[0]["type"])
print(hash(i) == hash(Isbn(value="1")))
print(Lenient(a=1, b=2).model_dump(), Keeper(a=1, b=2).model_dump(), Keeper(a=1, b=2).model_extra)
Source: Pydantic: Configuration
Output:
{'title': 'Dune', 'author': 'Frank Herbert'}
{'type': 'extra_forbidden', 'loc': ['body', 'year'], 'msg': 'Extra inputs are not permitted', 'input': 1965}
string_too_short
{'id': 7, 'title': 'Dune'}
id=7 title='Dune'
ValidationError frozen_instance
True
{'a': 1} {'a': 1, 'b': 2} {'b': 2}
Several of these are worth a closer look in FastAPI context:
-
extra="forbid"and the OpenAPI document. It addsadditionalProperties: falseto the schema, so generated clients and Swagger UI treat unknown keys as errors too. The default (ignore) silently drops unknown keys, which hides client typos such astitel. -
from_attributes=Trueand responses. A path function annotated→ BookOutmay return an object whose attributes match; FastAPI validates it againstBookOutand serializes only the model’s fields. This is the standard way to hide database columns such ashashed_password(see Response Models & Return Types and SQL Databases with SQLModel). -
frozen=Trueis shallow. A frozen model cannot be assigned to, but alistfield can still be mutated in place. -
Config is inherited, and a subclass can add to or override the parent’s
model_config. -
Fields whose names start with
model_used to trigger warnings; in 2.13 only names starting withmodel_validateormodel_dumpare protected. A field calledmodel_nameis fine; overrideprotected_namespaces=()if you must use the others.
Computed Fields and Aliases
@computed_field on a @property adds a derived value to the serialized output (and to the output JSON Schema)
without storing it as an input field. Aliases give a field an external name that differs from the Python attribute:
alias applies to input and output, validation_alias only to input, serialization_alias only to output;
AliasChoices accepts several input names and AliasPath reaches into nested input. An alias_generator
(for example pydantic.alias_generators.to_camel) derives all aliases from the field names.
from typing import Annotated
from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import (AliasChoices, AliasPath, BaseModel, ConfigDict, Field,
computed_field)
from pydantic.alias_generators import to_camel
class Book(BaseModel):
model_config = ConfigDict(alias_generator=to_camel, validate_by_name=True, validate_by_alias=True)
title: str
page_count: int
price_cents: int
@computed_field
@property
def price(self) -> float:
return self.price_cents / 100
class Hit(BaseModel):
# accept either key on input
isbn: str = Field(validation_alias=AliasChoices("isbn", "isbn13"))
# dig into a nested payload
author: str = Field(validation_alias=AliasPath("volumeInfo", "authors", 0))
# input alias differs from output alias
created_at: str = Field(validation_alias="createdAt", serialization_alias="created")
app = FastAPI()
@app.post("/books")
def create(book: Book) -> Book:
return book
@app.post("/hits", response_model_by_alias=True)
def hit(h: Hit) -> Hit:
return h
c = TestClient(app)
print(c.post("/books", json={"title": "Dune", "pageCount": 412, "priceCents": 999}).json())
print(c.post("/books", json={"title": "Dune", "page_count": 412, "price_cents": 999}).status_code)
print(c.post("/hits", json={"isbn13": "9780441172719", "volumeInfo": {"authors": ["Frank Herbert"]}, "createdAt": "2026-01-01"}).json())
spec = c.get("/openapi.json").json()["components"]["schemas"]
print(spec["Book-Output"]["properties"].keys(), spec["Book-Output"]["required"])
print(spec["Book-Input"]["properties"].keys())
Source: Pydantic: Alias
Output:
{'title': 'Dune', 'pageCount': 412, 'priceCents': 999, 'price': 9.99}
200
{'isbn': '9780441172719', 'author': 'Frank Herbert', 'created': '2026-01-01'}
dict_keys(['title', 'pageCount', 'priceCents', 'price']) ['title', 'pageCount', 'priceCents', 'price']
dict_keys(['title', 'pageCount', 'priceCents'])
What this shows about FastAPI:
-
The request used camelCase keys and the response did too: FastAPI serializes responses by alias by default (
response_model_by_alias=Trueon the decorator), whereas plainmodel_dump()uses field names unless you passby_alias=True. Pydantic 2.11 added theserialize_by_aliassetting; its default isFalseand is expected to becomeTruein Pydantic v3. -
Since Pydantic 2.11
validate_by_name=Truetogether withvalidate_by_alias=Trueis the current spelling of v1’sallow_population_by_field_name(populate_by_namestill works); setting both toFalseis an error. -
The computed field
priceis absent fromBook-Inputand present andrequiredinBook-Output, so the OpenAPI document correctly tells a client it can rely onpricein responses but must not send it. -
Aliases are what errors report in
loc: a missingpageCountis located at["body", "pageCount"], unless you setloc_by_alias=False. -
Aliases that are not valid Python identifiers (
"content-type") are the reasonField(alias=…)exists at all; for headers FastAPI instead derives the hyphenated name from the parameter name (see Headers & Cookies).
Serialization
Serialization turns a model into plain Python data or JSON. The methods and their most useful arguments:
| Call | Result |
|---|---|
|
A |
|
A |
|
A JSON |
|
Sets (or nested dicts) of field names to keep or drop. |
|
Drop fields that were not provided at construction. Basis of PATCH updates. |
|
Drop fields whose value equals their default. |
|
Drop fields whose value is |
|
Use aliases ( |
|
Serialize nested values by their actual runtime class instead of the annotated type. |
|
A copy with some fields replaced. The update values are not validated. |
|
The JSON Schema of the output shape ( |
Customisation hooks: @field_serializer("field") changes how one field is written, @model_serializer the whole
model, and SecretStr/SecretBytes mask values in output and in repr.
from datetime import datetime, timezone
from decimal import Decimal
from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import BaseModel, Field, SecretStr, field_serializer
class User(BaseModel):
username: str
password: SecretStr
nickname: str | None = None
karma: int = 0
joined: datetime = datetime(2026, 1, 1, tzinfo=timezone.utc)
balance: Decimal = Decimal("1.50")
handle: str = Field(default="u", serialization_alias="screenName")
@field_serializer("joined")
def short_date(self, v: datetime) -> str:
return v.date().isoformat()
u = User(username="ann", password="s3cret")
print(u.model_dump())
print(u.model_dump(mode="json"))
print(u.model_dump_json(exclude={"password"}, by_alias=True))
print(u.model_dump(exclude_unset=True, exclude={"password"}))
class Prefs(BaseModel):
theme: str = "light"
page_size: int = 20
p = Prefs(theme="light", page_size=50)
print(p.model_dump(exclude_unset=True), p.model_dump(exclude_defaults=True))
print(u.model_dump(exclude_none=True, include={"username", "nickname"}))
print(u.model_copy(update={"karma": 5}).karma)
print(User.model_json_schema(mode="serialization")["properties"]["joined"])
app = FastAPI()
@app.get("/me")
def me() -> User:
return u
c = TestClient(app)
print(c.get("/me").json())
Source: Pydantic: Serialization
Output:
{'username': 'ann', 'password': SecretStr('**********'), 'nickname': None, 'karma': 0, 'joined': '2026-01-01', 'balance': Decimal('1.50'), 'handle': 'u'}
{'username': 'ann', 'password': '**********', 'nickname': None, 'karma': 0, 'joined': '2026-01-01', 'balance': '1.50', 'handle': 'u'}
{"username":"ann","nickname":null,"karma":0,"joined":"2026-01-01","balance":"1.50","screenName":"u"}
{'username': 'ann'}
{'theme': 'light', 'page_size': 50} {'page_size': 50}
{'username': 'ann'}
5
{'title': 'Joined', 'type': 'string'}
{'username': 'ann', 'password': '**********', 'nickname': None, 'karma': 0, 'joined': '2026-01-01', 'balance': '1.50', 'screenName': 'u'}
How FastAPI uses this: the object a path function returns is validated against the response model (the return
annotation or response_model=), then serialized to JSON-compatible data by Pydantic (aliases used unless you turn them off); the
decorator options response_model_exclude_unset, response_model_exclude_defaults, response_model_exclude_none,
response_model_include, response_model_exclude and response_model_by_alias map onto the arguments above (all
described in Response Models & Return Types).
Subclasses are serialized by their declared type. A security-relevant change from v1: when a field is annotated with
a parent model and holds a subclass instance, only the parent’s fields are written. This protects against leaking
fields of a subclass such as an internal secret_tag, but surprises people who expected duck typing:
from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import BaseModel, ConfigDict
class Publication(BaseModel):
title: str
class Book(Publication):
isbn: str = "9780441172719"
class Shelf(BaseModel):
item: Publication
shelf = Shelf(item=Book(title="Dune"))
print(shelf.model_dump()) # only Publication's fields
print(shelf.model_dump(serialize_as_any=True)) # duck-typed: Book's fields too
class OpenPublication(BaseModel):
model_config = ConfigDict(polymorphic_serialization=True)
title: str
class OpenBook(OpenPublication):
isbn: str = "9780441172719"
class OpenShelf(BaseModel):
item: OpenPublication
print(OpenShelf(item=OpenBook(title="Dune")).model_dump())
app = FastAPI()
@app.get("/shelf")
def get_shelf() -> Shelf:
return shelf
print(TestClient(app).get("/shelf").json())
Source: Pydantic: Serialization
Output:
{'item': {'title': 'Dune'}}
{'item': {'title': 'Dune', 'isbn': '9780441172719'}}
{'item': {'title': 'Dune', 'isbn': '9780441172719'}}
{'item': {'title': 'Dune'}}
The last line shows that FastAPI follows the declared type, so the safe behaviour carries over to responses. Opt in to
duck typing per call with serialize_as_any=True or per model with polymorphic_serialization=True.
Strict vs Lax Validation
By default Pydantic validates in lax mode: it converts input to the target type when that is sensible and
unambiguous ("412" to 412, "yes" to True, an ISO string to a date). In strict mode a value must already
have the right type. This matters in FastAPI because the two ends of the pipe differ:
-
Query, path, header, cookie and form values are always text, so strict mode on such a parameter rejects everything that is not a
str. It is rarely what you want there. -
JSON bodies have a few native types (string, number, boolean, null, array, object). FastAPI validates the decoded JSON in lax mode, so
{"pages": "412"}is accepted for anintfield unless you opt into strictness.
from typing import Annotated
from fastapi import FastAPI, Query
from fastapi.testclient import TestClient
from pydantic import BaseModel, ConfigDict, Field, StrictInt, StrictStr, ValidationError
class Lax(BaseModel):
pages: int
in_print: bool
isbn: str
class Strict(BaseModel):
model_config = ConfigDict(strict=True)
pages: int
in_print: bool
isbn: str
payload = {"pages": "412", "in_print": "yes", "isbn": 9780441172719}
for cls in (Lax, Strict):
try:
print(cls.__name__, cls.model_validate(payload))
except ValidationError as e:
print(cls.__name__, [(err["loc"][0], err["type"]) for err in e.errors()])
print(Lax.model_validate({"pages": 412.0, "in_print": 1, "isbn": "x"}))
try:
Lax.model_validate({"pages": 412.5, "in_print": 1, "isbn": "x"})
except ValidationError as e:
print(e.errors()[0]["type"])
# per-call and per-field strictness
print(Lax.model_validate({"pages": 1, "in_print": True, "isbn": "x"}, strict=True))
class Mixed(BaseModel):
pages: StrictInt # only real ints
isbn: Annotated[str, Field(strict=True)] # same thing, other spelling
in_print: bool # still lax
app = FastAPI()
@app.post("/mixed")
def mixed(m: Mixed) -> Mixed:
return m
@app.get("/strict-query")
def strict_query(page: Annotated[int, Query(strict=True)] = 1) -> dict:
return {"page": page}
c = TestClient(app)
print(c.post("/mixed", json={"pages": "3", "isbn": "x", "in_print": "true"}).json()["detail"])
print(c.post("/mixed", json={"pages": 3, "isbn": "x", "in_print": "true"}).json())
print(c.get("/strict-query?page=2").json())
Source: Pydantic: Strict Mode
Output:
Lax [('isbn', 'string_type')]
Strict [('pages', 'int_type'), ('in_print', 'bool_type'), ('isbn', 'string_type')]
pages=412 in_print=True isbn='x'
int_from_float
pages=1 in_print=True isbn='x'
[{'type': 'int_type', 'loc': ['body', 'pages'], 'msg': 'Input should be a valid integer', 'input': '3'}]
{'pages': 3, 'isbn': 'x', 'in_print': True}
{'detail': [{'type': 'int_type', 'loc': ['query', 'page'], 'msg': 'Input should be a valid integer', 'input': '2'}]}
Strictness can be switched on per call (model_validate(data, strict=True)), per model (ConfigDict(strict=True)),
per field (Field(strict=True)) or by using a strict type (StrictInt, StrictStr, StrictBool, StrictFloat,
StrictBytes). The last three lines of the output show two facts: strictness in a body field works as expected, and
Query(strict=True) rejects even ?page=2, because the query string value is the text "2".
Strict mode is also not a security feature by itself: it controls coercion, not constraints. Constraints such as
ge=1 apply in both modes. The next script prints what lax and strict mode make of common inputs, the practical
content of Pydantic’s conversion table:
from datetime import date, datetime
from decimal import Decimal
from uuid import UUID
from pydantic import TypeAdapter, ValidationError
cases = [
(int, "42"), (int, 42.0), (int, 42.5), (int, True), (int, "4_2"),
(float, "1e3"), (float, 3),
(bool, "yes"), (bool, "off"), (bool, 1), (bool, 2), (bool, "maybe"),
(str, 5), (str, b"abc"),
(date, "2026-10-02"), (date, 1_700_000_000), (datetime, "2026-10-02T10:00:00Z"),
(UUID, "12345678-1234-5678-1234-567812345678"),
(Decimal, "9.99"), (Decimal, 9.99),
(list[int], "123"), (list[int], (1, "2")), (set[int], [1, 1, 2]),
(dict[str, int], [("a", 1)]),
]
for tp, value in cases:
try:
lax = repr(TypeAdapter(tp).validate_python(value))
except ValidationError as e:
lax = "error " + e.errors()[0]["type"]
try:
strict = repr(TypeAdapter(tp).validate_python(value, strict=True))
except ValidationError as e:
strict = "error " + e.errors()[0]["type"]
name = getattr(tp, "__name__", str(tp))
print(f"{name:14} {value!r:40} lax: {lax:45} strict: {strict}")
for text in ('"42"', "42"):
try:
print(text, TypeAdapter(int).validate_json(text, strict=True))
except ValidationError as e:
print(text, e.errors()[0]["type"])
print(TypeAdapter(date).validate_json('"2026-10-02"', strict=True))
Source: Pydantic: Conversion Table
Selected results of that run:
| Target type | Input | Lax | Strict |
|---|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Two lines of the printed tail matter for FastAPI. validate_json('"42"', strict=True) for an int is an int_type
error while validate_json("42", strict=True) succeeds, and a strict date is accepted from a JSON string, because
JSON has no date type. Remember also that FastAPI parses the body to Python objects first and validates those, so the
Python-mode rows above are what governs a JSON body.
The Structure of a 422
When validation of any parameter or body fails, FastAPI does not call your function. It raises
RequestValidationError and its default handler answers 422 Unprocessable Content with a JSON object whose
detail is the list of Pydantic errors, all of them (not only the first), each from every source (path, query,
header, cookie, body):
curl -s -X POST localhost:8000/reviews \
-H 'Content-Type: application/json' \
-d '{"stars": 9, "text": "ok"}'
{"detail":[{"type":"less_than_equal","loc":["body","stars"],"msg":"Input should be less than or equal to 5","input":9,"ctx":{"le":5}},{"type":"string_too_short","loc":["body","text"],"msg":"String should have at least 3 characters","input":"ok","ctx":{"min_length":3}}]}
The same request with "stars": "5", "text": "great" returned 200 and {"stars":5,"text":"great"}: the string "5" was
coerced to an integer in lax mode. The full anatomy, from a request that fails in the path, the query string and the
body at once:
import json
from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import BaseModel, Field
class Review(BaseModel):
stars: int = Field(ge=1, le=5)
text: str = Field(min_length=3)
tags: list[int] = []
app = FastAPI()
@app.post("/books/{book_id}/reviews")
def add(book_id: int, review: Review, page: int = 1) -> Review:
return review
c = TestClient(app)
r = c.post("/books/x/reviews?page=a", json={"stars": 9, "text": "ok", "tags": [1, "z"]})
print(r.status_code)
print(json.dumps(r.json()))
JSON = {"Content-Type": "application/json"}
print(c.post("/books/1/reviews", content="{bad json", headers=JSON).json())
print(c.post("/books/1/reviews", json=[1]).json())
print(c.post("/books/1/reviews", json={}).json())
Source: FastAPI: Handling Errors
Output (the first line is the status code, then the body):
422
{"detail": [{"type": "int_parsing", "loc": ["path", "book_id"], "msg": "Input should be a valid integer, unable to parse string as an integer", "input": "x"}, {"type": "int_parsing", "loc": ["query", "page"], "msg": "Input should be a valid integer, unable to parse string as an integer", "input": "a"}, {"type": "less_than_equal", "loc": ["body", "stars"], "msg": "Input should be less than or equal to 5", "input": 9, "ctx": {"le": 5}}, {"type": "string_too_short", "loc": ["body", "text"], "msg": "String should have at least 3 characters", "input": "ok", "ctx": {"min_length": 3}}, {"type": "int_parsing", "loc": ["body", "tags", 1], "msg": "Input should be a valid integer, unable to parse string as an integer", "input": "z"}]}
{'detail': [{'type': 'json_invalid', 'loc': ['body', 1], 'msg': 'JSON decode error', 'input': {}, 'ctx': {'error': 'Expecting property name enclosed in double quotes'}}]}
{'detail': [{'type': 'model_attributes_type', 'loc': ['body'], 'msg': 'Input should be a valid dictionary or object to extract fields from', 'input': [1]}]}
{'detail': [{'type': 'missing', 'loc': ['body', 'stars'], 'msg': 'Field required', 'input': {}}, {'type': 'missing', 'loc': ['body', 'text'], 'msg': 'Field required', 'input': {}}]}
| Field | Meaning |
|---|---|
|
A stable machine-readable code ( |
|
Where the problem is: the source ( |
|
A human-readable English message. It is not stable across versions and is not translated. |
|
The offending value as received. This can echo sensitive data back to the client; see the pitfall about |
|
Optional values of the failed rule ( |
The error type values are catalogued in Pydantic’s validation errors reference. Notes:
-
A malformed JSON body gives one error of
typejson_invalidwithloc["body", <character position>], and a body that is JSON but not an object givesmodel_attributes_typeat["body"]. -
Pydantic’s own error dictionaries also carry a
urlpointing at its documentation; FastAPI’s handler drops it, which the output above confirms. -
v1 payloads looked different (
type_error.integer,value_error.missing,ctx.limit_value). Any client, test or front end that matches on those strings must be updated; see Migrating from Pydantic v1 to v2.
To change the format of the response, register a handler for RequestValidationError; to translate or extend the
messages, build them from type and ctx. Register handlers before the application serves its first request:
from fastapi import FastAPI, Request
from fastapi.encoders import jsonable_encoder
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from fastapi.testclient import TestClient
from pydantic import BaseModel, Field
class Review(BaseModel):
stars: int = Field(ge=1, le=5)
text: str = Field(min_length=3)
app = FastAPI()
@app.exception_handler(RequestValidationError)
async def flatten(request: Request, exc: RequestValidationError) -> JSONResponse:
problems = {".".join(str(p) for p in e["loc"]): e["msg"] for e in exc.errors()}
return JSONResponse(status_code=422, content=jsonable_encoder({"problems": problems}))
@app.post("/reviews")
def add(review: Review) -> Review:
return review
c = TestClient(app)
print(c.post("/reviews", json={"stars": 9, "text": "ok"}).json())
Source: FastAPI: Handling Errors
Output:
{'problems': {'body.stars': 'Input should be less than or equal to 5', 'body.text': 'String should have at least 3 characters'}}
Validation errors echo the offending value back in input. Pydantic’s hide_input_in_errors setting does not change
that in the response FastAPI builds, so if a request model carries passwords or tokens, remove the sensitive keys in
your own handler. The script runs the same request against an app with and without such a handler:
from fastapi import FastAPI, Request
from fastapi.encoders import jsonable_encoder
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from fastapi.testclient import TestClient
from pydantic import BaseModel, ConfigDict, Field
class Signup(BaseModel):
model_config = ConfigDict(hide_input_in_errors=True) # hides input in ValidationError.__str__ ...
username: str = Field(min_length=3)
password: str = Field(min_length=12)
def make_app(strip_input: bool) -> FastAPI:
app = FastAPI()
if strip_input:
@app.exception_handler(RequestValidationError)
async def handler(request: Request, exc: RequestValidationError) -> JSONResponse:
errors = [{k: v for k, v in e.items() if k not in ("input", "ctx")} for e in exc.errors()]
return JSONResponse(status_code=422, content=jsonable_encoder({"detail": errors}))
@app.post("/signup")
def signup(s: Signup) -> dict:
return {"ok": True}
return app
body = {"username": "ab", "password": "short"}
print(TestClient(make_app(False)).post("/signup", json=body).json()["detail"][1])
print(TestClient(make_app(True)).post("/signup", json=body).json()["detail"][1])
Source: Pydantic: Configuration
Output:
{'type': 'string_too_short', 'loc': ['body', 'password'], 'msg': 'String should have at least 12 characters', 'input': 'short', 'ctx': {'min_length': 12}}
{'type': 'string_too_short', 'loc': ['body', 'password'], 'msg': 'String should have at least 12 characters'}
The status-code constants, HTTPException and the default handlers are covered in
Status Codes & Error Handling. When you test an API,
assert on type and loc, which are stable, and avoid asserting on msg (see
Testing).
pydantic-extra-types and Other Special Types
Pydantic ships many specialised types in pydantic.types and in the network types (HttpUrl, AnyUrl, PostgresDsn,
EmailStr, NameEmail, IPvAnyAddress). EmailStr needs the third-party email-validator package. Types that are
domain-specific live in a separate distribution, pydantic-extra-types (installed with fastapi[standard]; some
types need an optional dependency such as pycountry for Country, phonenumbers for PhoneNumber or python-ulid
for ULID, available as extras of the package). The modules in the installed version (2.11.1) include color,
coordinate, country, currency_code, cron, domain, dsn, epoch, iban, isbn, language_code,
mac_address, mime_types, mongo_object_id, payment, pendulum_dt, phone_numbers, routing_number, s3,
script_code, semantic_version, semver, timezone_name, ulid and uuid_types.
from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import BaseModel, EmailStr, HttpUrl, SecretStr
from pydantic_extra_types.color import Color
from pydantic_extra_types.coordinate import Coordinate
from pydantic_extra_types.isbn import ISBN
from pydantic_extra_types.mac_address import MacAddress
class Store(BaseModel):
owner: EmailStr # needs email-validator (part of fastapi[standard])
website: HttpUrl
isbn: ISBN # validates the ISBN check digit, normalises to ISBN-13
shelf_color: Color
where: Coordinate
scanner: MacAddress
app = FastAPI()
@app.post("/stores")
def create(store: Store) -> Store:
return store
c = TestClient(app)
good = {"owner": "ann@example.com", "website": "https://example.com/shop", "isbn": "0441172717",
"shelf_color": "tomato", "where": "40.4168, -3.7038", "scanner": "00:1B:44:11:3A:B7"}
r = c.post("/stores", json=good)
print(r.status_code, r.json())
bad = dict(good, isbn="9780441172710", owner="not-an-email", shelf_color="nope")
for e in c.post("/stores", json=bad).json()["detail"]:
print(e["loc"], e["type"], "|", e["msg"])
print(c.get("/openapi.json").json()["components"]["schemas"]["Store-Input"]["properties"]["isbn"])
Output:
200 {'owner': 'ann@example.com', 'website': 'https://example.com/shop', 'isbn': '9780441172719', 'shelf_color': 'tomato', 'where': {'latitude': 40.4168, 'longitude': -3.7038}, 'scanner': '00:1b:44:11:3a:b7'}
['body', 'owner'] value_error | value is not a valid email address: An email address must have an @-sign.
['body', 'isbn'] isbn_invalid_digit_check_isbn13 | Provided digit is invalid for given ISBN
['body', 'shelf_color'] color_error | value is not a valid color: string not recognised as a valid color
{'type': 'string', 'title': 'Isbn'}
The ISBN type is useful for the Bookshelf scenario: it checks the check digit and normalises an ISBN-10 to
ISBN-13 (0441172717 became 9780441172719), but it rejects hyphenated input, so strip hyphens in a before
validator if your clients send them. The OpenAPI schema of such a type is a plain string: the constraint is enforced
by validation and is invisible to API consumers, which is a trade-off to weigh for public APIs (add description or
examples).
Also available from FastAPI’s own documentation are the extra data types (UUID, datetime, date, time,
timedelta, frozenset, bytes, Decimal), covered in
Request Body & Models.
Effect on OpenAPI
FastAPI asks Pydantic for the JSON Schema of every model and parameter and embeds it in /openapi.json; Swagger UI and
ReDoc render that document. Therefore each Pydantic feature has a visible counterpart:
| Pydantic | OpenAPI / Swagger UI |
|---|---|
Required field (no default) |
Listed in |
|
|
|
Text, example values and a strike-through field in Swagger UI. |
|
Any extra keywords, for example a model-level |
|
|
|
A |
Aliases |
The alias is the property name; |
Validators ( |
Nothing: arbitrary Python cannot be described. Document the rule in |
|
The field’s schema becomes empty ( |
|
|
from typing import Annotated
from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import BaseModel, ConfigDict, Field, computed_field
class Book(BaseModel):
model_config = ConfigDict(
title="Book",
json_schema_extra={"examples": [{"title": "Dune", "pages": 412}]},
)
title: Annotated[str, Field(min_length=1, max_length=200, description="Title as printed", examples=["Dune"])]
pages: Annotated[int, Field(gt=0, deprecated=True)] = 1
@computed_field(description="Rough reading time")
@property
def reading_hours(self) -> float:
return round(self.pages / 40, 1)
app = FastAPI()
@app.post("/books")
def create(book: Book) -> Book:
return book
c = TestClient(app)
schemas = c.get("/openapi.json").json()["components"]["schemas"]
inp, out = schemas["Book-Input"], schemas["Book-Output"]
print(inp["required"], out["required"])
print(inp["properties"]["title"])
print(inp["properties"]["pages"])
print(out["properties"]["reading_hours"])
print(inp["examples"])
Source: Pydantic: JSON Schema
Output:
['title'] ['title', 'reading_hours']
{'type': 'string', 'maxLength': 200, 'minLength': 1, 'title': 'Title', 'description': 'Title as printed', 'examples': ['Dune']}
{'type': 'integer', 'exclusiveMinimum': 0.0, 'title': 'Pages', 'default': 1, 'deprecated': True}
{'type': 'number', 'title': 'Reading Hours', 'description': 'Rough reading time', 'readOnly': True}
[{'pages': 412, 'title': 'Dune'}]
OpenAPI 3.1 (supported since FastAPI 0.99.0, 2023-06-30) and JSON Schema 2020-12 allow the examples keyword on
schemas and fields, which is why examples appears directly rather than the older singular example. The page
OpenAPI & Interactive Docs explains how to curate the
document further.
The Validation Flow
Putting the pieces in order: what happens to a JSON body between the socket and the response?
bytes"] --> ct{"Content-Type
application/json?"} ct -- "no (strict check)" --> raw_text["Body kept as text"] ct -- yes --> parse["Decode JSON
to Python objects"] parse -->|"invalid JSON"| e422 raw_text --> mb parse --> mb["Model before validators"] mb --> fields subgraph fields["For each field, in declaration order"] direction LR fb["Field before
validators"] --> coerce{"Coercion
lax or strict"} coerce --> cons["Constraints
ge, le, pattern..."] cons --> fa["Field after
validators"] end coerce -->|"cannot convert"| e422 cons -->|"violated"| e422 fa --> ma["Model after validators"] ma --> model["Model instance"] ma -->|"ValueError"| e422["RequestValidationError
422 with all errors"] model --> fn["Path operation function"] fn --> ret["Return value validated
against the response model"] ret --> ser["Serialization
aliases, exclude, serializers"] ser --> out["JSON response"]
The Content-Type branch is FastAPI’s strict content type check (0.132.0), described in
Request Object & Content-Type. Reading the figure: errors never short-circuit the other fields (all problems are collected into one 422); the
function runs only if the whole model is valid; and the return value takes a second trip through Pydantic, as the
response model. The next script prints the order in which the hooks actually fire in a running application:
from typing import Any
from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import BaseModel, field_serializer, field_validator, model_validator
trace: list[str] = []
class Review(BaseModel):
stars: int
@model_validator(mode="before")
@classmethod
def m_before(cls, data: Any) -> Any:
trace.append(f"model before: {data!r}")
return data
@field_validator("stars", mode="before")
@classmethod
def f_before(cls, v: Any) -> Any:
trace.append(f"field before: {v!r}")
return v
@field_validator("stars", mode="after")
@classmethod
def f_after(cls, v: int) -> int:
trace.append(f"field after: {v!r}")
return v
@model_validator(mode="after")
def m_after(self) -> "Review":
trace.append(f"model after: {self!r}")
return self
@field_serializer("stars")
def ser(self, v: int) -> int:
trace.append(f"serialize: {v!r}")
return v
app = FastAPI()
@app.post("/reviews")
def create(review: Review) -> Review:
trace.append("handler")
return review
c = TestClient(app)
print(c.post("/reviews", json={"stars": "5"}).json())
print("\n".join(trace))
Source: Pydantic: Validators
Output:
{'stars': 5}
model before: {'stars': '5'}
field before: '5'
field after: 5
model after: Review(stars=5)
handler
model after: Review(stars=5)
serialize: 5
The input "5" was converted to 5 between the before and after hooks. After the handler returned, the response
passed through the response model again: the after model validator fired a second time, while the field validators
did not, because an instance that is already of the right class is not re-validated field by field
(revalidate_instances="never"). This is the behaviour of the versions named at the top of the page; do not write
validators with side effects, because they can run more than once per request.
Migrating from Pydantic v1 to v2
Pydantic 2.0 was released on 2023-06-30; code written for v1 (including the code in books and tutorials from 2021 and 2022) does not run unchanged. FastAPI supported both major versions side by side for a long period and has now dropped v1 entirely (timeline below), so any project still on v1 must migrate to move past FastAPI 0.125. The official migration guide is the authoritative reference; this section is a field guide verified against the installed versions.
FastAPI’s Pydantic v1 Removal Timeline
| FastAPI version | Date | What changed |
|---|---|---|
0.100.0 |
2023-07-07 |
Supports Pydantic v1 or v2, whichever is installed. |
0.119.0 |
2025-10-11 |
Adds partial support for Pydantic v1 models through |
0.126.0 |
2025-12-20 |
Drops support for Pydantic v1 (temporarily still supports |
0.128.0 |
2025-12-27 |
Drops support for |
Two consequences follow. A pydantic.v1 model used in a path operation of a current FastAPI raises an error.
And the Pydantic team no longer supports pydantic.v1 on Python 3.14 and later, so even apps that cling to the compatibility
namespace cannot move to the newest Python. The only supported path is to migrate the models. Mixing is not allowed
either: a v2 model cannot have fields that are v1 models or the reverse.
The Name Table
Every row below was checked by running the v1 name on Pydantic 2.13.5 (the probe scripts follow the table).
| Pydantic v1 | Pydantic v2 | Notes (verified behaviour in 2.13.5) |
|---|---|---|
Methods of |
||
|
|
The v1 name still works but emits |
|
|
|
|
|
Deprecated alias works. |
|
|
Deprecated alias works. |
|
|
|
|
|
The update is not validated in either version. |
|
|
Builds without validating, so bad values go in silently. |
|
|
Deprecated alias works. |
|
|
Deprecated alias works. |
|
|
Deprecated alias works. |
|
|
Deprecated alias works. |
|
|
Maps names to |
|
|
Deprecated alias works. |
|
|
|
|
|
Validates and documents types that are not models, such as |
|
|
A field called |
|
|
|
|
|
Renamed. |
model equality with a dict |
not equal |
In v2 a model is only equal to another model. |
|
||
|
|
The inner class still works but warns. Several keys were removed (see the last rows of this group). |
|
|
Renamed. |
|
|
Renamed. |
|
|
Renamed. |
|
|
Renamed. |
|
|
Renamed. |
|
|
Renamed. |
|
|
Renamed. |
|
|
The same rename exists on |
|
|
Renamed. |
|
|
Deprecated (performance, complexity); prefer the serializer decorators. |
|
no equivalent |
Removed (the underscore behaviour is always on). |
|
|
|
Fields and constraints |
||
|
no default |
Ellipsis still means required, but the idiom is to write no default. |
|
|
|
|
|
Deprecated in |
|
a |
|
|
|
|
|
|
Extra keyword arguments on |
|
|
The |
|
required, nullable |
In v1 it was optional with default |
|
|
Constraints are no longer pushed down into containers. |
|
removed |
Use |
|
returns |
Changed in |
Validators |
||
|
|
|
|
|
Add |
|
annotate the item type: |
No |
|
|
In v2 type validation also runs on defaults, not only your function. |
|
|
- |
|
|
v1’s |
|
delete it |
No longer necessary. |
Errors and imports |
||
|
|
Old path still imports with a deprecation warning. |
|
|
New type names, new |
|
same, still needs |
Unchanged. |
The first script calls every deprecated method on a v2 model and reports whether it works and which warning it emits:
"""Call every Pydantic v1 name on a v2 model and report what happens."""
import warnings
from typing import Annotated
from pydantic import BaseModel, Field, PydanticDeprecatedSince20, TypeAdapter
class Book(BaseModel):
title: str
pages: int = 100
b = Book(title="Dune")
calls = {
"b.dict()": lambda: b.dict(),
"b.json()": lambda: b.json(),
"Book.parse_obj(d)": lambda: Book.parse_obj({"title": "x"}),
"Book.parse_raw(s)": lambda: Book.parse_raw('{"title": "x"}'),
"Book.from_orm(o)": lambda: Book.from_orm(b),
"b.copy(update=)": lambda: b.copy(update={"pages": 1}),
"Book.construct()": lambda: Book.construct(title="x"),
"Book.schema()": lambda: Book.schema(),
"Book.schema_json()": lambda: Book.schema_json(),
"Book.update_forward_refs()": lambda: Book.update_forward_refs(),
"Book.validate(d)": lambda: Book.validate({"title": "x"}),
"b.__fields__": lambda: b.__fields__,
"b.__fields_set__": lambda: b.__fields_set__,
}
for name, fn in calls.items():
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
try:
fn()
status = "works"
except Exception as e:
status = f"{type(e).__name__}"
kinds = {x.category.__name__ for x in w}
print(f"{name:28} {status:8} {', '.join(sorted(kinds)) or 'no warning'}")
from pydantic.tools import parse_obj_as
with warnings.catch_warnings(record=True) as w:
warnings.simplefilter("always")
print(parse_obj_as(list[int], ["1", 2]), [x.category.__name__ for x in w])
print(TypeAdapter(list[int]).validate_python(["1", 2]))
Source: Pydantic: Migration Guide
Output:
b.dict() works PydanticDeprecatedSince20
b.json() works PydanticDeprecatedSince20
Book.parse_obj(d) works PydanticDeprecatedSince20
Book.parse_raw(s) works PydanticDeprecatedSince20
Book.from_orm(o) PydanticUserError PydanticDeprecatedSince20
b.copy(update=) works PydanticDeprecatedSince20
Book.construct() works PydanticDeprecatedSince20
Book.schema() works PydanticDeprecatedSince20
Book.schema_json() works PydanticDeprecatedSince20
Book.update_forward_refs() works PydanticDeprecatedSince20
Book.validate(d) works PydanticDeprecatedSince20
b.__fields__ works PydanticDeprecatedSince20
b.__fields_set__ works PydanticDeprecatedSince20
[1, 2] ['PydanticDeprecatedSince20']
[1, 2]
The second probes configuration, Field() arguments, validators and import paths (the label column is the v1
spelling):
"""v1 spellings that are deprecated, renamed or removed: what Pydantic 2.13 does with each."""
import warnings
from typing import Any
from pydantic import BaseModel, Field, root_validator, validator
def probe(label: str, build) -> None:
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
try:
build()
result = "works"
except Exception as exc:
result = f"{type(exc).__name__}"
kinds = sorted({w.category.__name__ for w in caught})
print(f"{label:34} {result:18} {', '.join(kinds) or '-'}")
def with_field(annotation: Any, make_field):
return lambda: type("M", (BaseModel,), {"__annotations__": {"x": annotation}, "x": make_field()})
def class_config():
class M(BaseModel):
x: int
class Config:
orm_mode = True
schema_extra = {"example": {"x": 1}}
def old_validator():
class M(BaseModel):
x: list[int]
@validator("x", each_item=True)
def check(cls, v):
return v
def old_root_validator():
class M(BaseModel):
x: int
@root_validator(skip_on_failure=True)
def check(cls, values):
return values
def old_root():
class M(BaseModel):
__root__: list[int]
def import_(statement: str):
return lambda: exec(statement, {})
probe("class Config (orm_mode, ...)", class_config)
probe("Field(regex=...)", with_field(str, lambda: Field(regex="a")))
probe("Field(min_items=1)", with_field(list[int], lambda: Field(min_items=1)))
probe("Field(unique_items=True)", with_field(list[int], lambda: Field(unique_items=True)))
probe("Field(const=True)", with_field(int, lambda: Field(1, const=True)))
probe("Field(example=...)", with_field(int, lambda: Field(1, example=2)))
probe("Field(allow_mutation=False)", with_field(int, lambda: Field(1, allow_mutation=False)))
probe("@validator(each_item=True)", old_validator)
probe("@root_validator", old_root_validator)
probe("__root__ field", old_root)
probe("pydantic.generics.GenericModel", import_("from pydantic.generics import GenericModel"))
probe("from pydantic import BaseSettings", import_("from pydantic import BaseSettings"))
probe("pydantic.error_wrappers", import_("from pydantic.error_wrappers import ValidationError"))
probe("conlist(int, min_items=1)", import_("from pydantic import conlist; conlist(int, min_items=1)"))
probe("conlist(int, min_length=1)", import_("from pydantic import conlist; conlist(int, min_length=1)"))
Source: Pydantic: Migration Guide
Output:
class Config (orm_mode, ...) works PydanticDeprecatedSince20, UserWarning
Field(regex=...) PydanticUserError -
Field(min_items=1) works PydanticDeprecatedSince20
Field(unique_items=True) PydanticUserError -
Field(const=True) PydanticUserError -
Field(example=...) works PydanticDeprecatedSince20
Field(allow_mutation=False) works PydanticDeprecatedSince20
@validator(each_item=True) works PydanticDeprecatedSince20
@root_validator works PydanticDeprecatedSince20
__root__ field TypeError -
pydantic.generics.GenericModel works PydanticDeprecatedSince20
from pydantic import BaseSettings PydanticImportError -
pydantic.error_wrappers works PydanticDeprecatedSince20
conlist(int, min_items=1) TypeError -
conlist(int, min_length=1) works -
A deprecated spelling that prints a PydanticDeprecatedSince20 warning is safe to run during the migration, but treat
the warning as a to-do list: the deprecated names are scheduled for removal in a future major version. Run your test
suite with python -W error::DeprecationWarning (or the filterwarnings setting of pytest) to turn them into
failures. The third script shows the v2 spelling of each idiom together with the behavioural changes that cause bugs
rather than exceptions:
"""The v2 spelling of each v1 idiom, plus the behaviour changes that bite during a migration."""
from typing import Annotated, Generic, TypeVar
from pydantic import (BaseModel, ConfigDict, Field, RootModel, TypeAdapter, ValidationError,
field_validator)
class Book(BaseModel):
title: str
pages: int = 100
b = Book(title="Dune")
print(b.model_dump(), b.model_dump_json()) # dict(), json()
print(Book.model_validate({"title": "x"})) # parse_obj
print(Book.model_validate_json('{"title": "x"}')) # parse_raw
print(b.model_copy(update={"pages": 1})) # copy(update=)
print(Book.model_construct(title="x", pages="not checked")) # construct (no validation!)
print(Book.model_json_schema()["required"]) # schema()
print(list(Book.model_fields), b.model_fields_set) # __fields__, __fields_set__
print(TypeAdapter(list[Book]).validate_python([{"title": "a"}])) # parse_obj_as
print(TypeAdapter(list[int]).json_schema()) # schema_of
# __root__ -> RootModel
class Tags(RootModel[list[str]]):
pass
t = Tags(["a", "b"])
print(t.root, t.model_dump())
# GenericModel -> BaseModel + Generic
T = TypeVar("T")
class Page(BaseModel, Generic[T]):
items: list[T]
total: int
print(Page[Book](items=[{"title": "x"}], total=1))
# each_item=True -> constraints on the item type; Field(...) is no longer pushed down
class Bag(BaseModel):
isbns: list[Annotated[str, Field(pattern=r"^\d+$")]]
try:
Bag(isbns=["1", "x"])
except ValidationError as e:
print(e.errors()[0]["loc"], e.errors()[0]["type"])
class Pushdown(BaseModel):
isbns: list[str] = Field(pattern=r"^\d+$") # v1 applied this to every item
try:
Pushdown(isbns=["1"])
except Exception as e:
print(type(e).__name__, e)
# behaviour changes
class Opt(BaseModel):
required_nullable: str | None # v1: optional, default None. v2: REQUIRED
optional_nullable: str | None = None
try:
Opt()
except ValidationError as e:
print([x["loc"][0] for x in e.errors()])
class Num(BaseModel):
s: str
try:
Num(s=5) # v1 coerced to "5"
except ValidationError as e:
print(e.errors()[0]["type"])
print(repr(TypeAdapter(str | int).validate_python("1")), repr(TypeAdapter(int | str).validate_python("1"))) # smart union keeps the str
class Coerce(BaseModel):
model_config = ConfigDict(coerce_numbers_to_str=True)
s: str
print(Coerce(s=5))
class Dflt(BaseModel):
x: int = Field("7", validate_default=True) # v1: always=True
print(Dflt())
try:
Book("Dune")
except TypeError:
print("keyword-only")
# models are no longer equal to dicts
print(Book(title="x") == {"title": "x", "pages": 100}, Book(title="x") == Book(title="x"))
# subclasses: only the declared fields are serialized (opt out with serialize_as_any)
class Pet(BaseModel):
name: str
class Dog(Pet):
secret_tag: str = "chip-1"
class Owner(BaseModel):
pet: Pet
o = Owner(pet=Dog(name="Rex"))
print(o.model_dump(), o.model_dump(serialize_as_any=True))
Source: Pydantic: Migration Guide
Output:
{'title': 'Dune', 'pages': 100} {"title":"Dune","pages":100}
title='x' pages=100
title='x' pages=100
title='Dune' pages=1
title='x' pages='not checked'
['title']
['title', 'pages'] {'title'}
[Book(title='a', pages=100)]
{'items': {'type': 'integer'}, 'type': 'array'}
['a', 'b'] ['a', 'b']
items=[Book(title='x', pages=100)] total=1
('isbns', 1) string_pattern_mismatch
TypeError Unable to apply constraint 'pattern' to supplied value ['1'] for schema of type 'list'
['required_nullable']
string_type
'1' '1'
s='5'
x=7
keyword-only
False True
{'pet': {'name': 'Rex'}} {'pet': {'name': 'Rex', 'secret_tag': 'chip-1'}}
Behaviour Changes That Do Not Raise
The renames above fail loudly. These changes keep running and give different results, so check each in your own code and tests:
-
Optional[X]without a default is required. A v1 model in which clients could omit the key now answers422missingfor that key (therequired_nullableline of the output). Add= None. -
No more number-to-string coercion.
Num(s=5)isstring_typein v2, where v1 produced"5". Opt back in withcoerce_numbers_to_str=True(model or field), or fix the client. -
Smart unions.
str | intandint | strboth keep"1"as a string in v2 because the input already matches a member; v1 tried members from left to right and converted. UseField(union_mode="left_to_right")for the old behaviour and discriminated unions (discriminator=) for tagged data. -
Defaults. A v1
@validator(always=True)ran only your function on a default. In v2 the same effect needsField(validate_default=True), and it validates the default against the type too, so a default of the wrong type (x: str = 1) now fails. -
Dicts from pairs. Iterables of pairs no longer validate as
dict. -
Subclass serialization writes only the declared type’s fields (see Serialization).
-
Models are not equal to dicts,
parse_objcopies input andconstruct/model_constructdo not validate. -
Dataclasses. Tuples are no longer accepted as input for dataclass fields,
post_initruns after validation,extra="allow"is unsupported andpydantic_modelis gone (wrap the dataclass in aTypeAdapterfor validation and schemas). -
JSON Schema of
Optionalfields now saysnullis allowed (anyOfwith{"type": "null"}), which changes generated OpenAPI clients. -
Error output differs, as discussed in The Structure of a 422.
-
Models are keyword-only: positional construction is a
TypeError.
Automating It with bump-pydantic
The Pydantic team maintains a code-transformation tool, bump-pydantic, that rewrites most v1 idioms in a package. It
is a command-line tool installed with pip install bump-pydantic (latest release on PyPI: 0.8.0, 2023-12-28; the
official migration guide still calls it beta). Run it on a clean git work tree, from the repository root, pointing at
the package directory:
pip install bump-pydantic
cd /path/to/repository
bump-pydantic --diff my_package # preview the changes
bump-pydantic my_package # apply them
Source: GitHub: pydantic/bump-pydantic
The tool applies a fixed list of rules (BP001 to BP009): add the default None to Optional[T] fields, replace
Config classes by model_config, rename old Field parameters, replace moved imports, replace GenericModel
and root, replace validator decorators, replace the con* functions by Annotated, and mark Pydantic "protocol"
methods of custom types with TODO comments. Individual rules can be disabled from the command line. FastAPI’s own
guide recommends tests, then bump-pydantic, then the tests again. A practical order for an existing application:
-
Make sure the application has tests and runs them in CI.
-
Run
bump-pydanticand review the diff. -
Run the tests, fix what remains by hand, using the tables above.
-
Upgrade FastAPI to the current release; remember that the response bodies of validation errors changed shape.
Do not skip the review: the tool transforms syntax, not meaning, so the behaviour changes listed in the previous
section still need human attention, in particular smart unions, number coercion and Optional fields that must stay
optional.
Where Older Guides Are Outdated, and What Changed Recently
-
Pydantic v1 code (
.dict(),class Config,orm_mode,@validator,regex=,min_items,Field(…)for required fields,value_error.missingerror types) is the style used by most FastAPI material written before mid-2023, including popular books. Map it with the tables in Migrating from Pydantic v1 to v2; the models in this section never use it. -
Optional[X]is still sometimes presented as "optional parameter". It only says the value may beNone. -
pydantic.v1is not a way to keep old code running any more: FastAPI stopped supporting it in 0.128.0 (2025-12-27), and Python 3.14 is unsupported by it. -
0.115.0 (2024-09-17) added Pydantic models for
Query,HeaderandCookieparameters and 0.113.0 (2024-09-05) forForm; both acceptextra="forbid". -
0.100.0 (2023-07-07) introduced Pydantic v2 support; 0.102.0 (2023-08-25) added separate input and output schemas; 0.99.0 (2023-06-30) switched OpenAPI to 3.1 and JSON Schema 2020-12 (
examplesinstead ofexample). -
0.130.0 (2026-02-22) serializes responses with Pydantic’s Rust JSON serializer when a return type or response model is declared, which is faster than building Python dictionaries first; see Response Models & Return Types.
-
0.128.2 (2026-02-05) added support for
TypeAliasType(PEP 695typealiases) in annotations, so reusable constraint aliases can use thetypestatement on Python 3.12 and later. -
Pydantic 2.11 (2025-03-27) introduced
validate_by_name,validate_by_aliasandserialize_by_alias. -
The migration of the book’s era is summarised for the whole framework in What’s Changed & Migration.
Pitfalls
-
Optionalis not "optional".str | Nonewithout= Noneis required. The OpenAPIrequiredlist and the422missingerror show it. -
Validators raise
ValueError, notHTTPException. Validators run outside the HTTP layer; map errors to HTTP at the boundary. -
ctx.erroris empty. Exceptions raised in validators appear as{}inctx; put the information in the message. -
Echoed input.
422bodies repeatinput, including passwords and tokens.hide_input_in_errors=Trueinmodel_configdoes not hide it in FastAPI’s response (verified above); stripinput(andctx) in aRequestValidationErrorhandler. -
Strict query parameters.
Query(strict=True)rejects everything, because query values are strings. -
Constraints on containers.
Field(pattern=…)on alist[str]does not constrain the items; uselist[Annotated[str, Field(…)]]. -
Default values are not validated.
x: int = "7"stays the string"7"unlessvalidate_default=True. -
Mutable models. A model is mutable and not validated on assignment by default;
frozen=Trueprevents assignment but does not freeze nested lists. -
model_constructandmodel_copy(update=)skip validation, so never use them on untrusted data. -
Handlers registered late. An exception handler added after the app served its first request (as the TestClient does) is ignored; register handlers at start-up.
-
Validators that depend on field order.
info.dataonly contains fields declared earlier and fields that passed validation, so a failedpasswordleaves noinfo.data["password"]. -
Side effects in validators. They can run again when a response is validated (see The Validation Flow).
-
Responses leak by default. Without a response model, everything you return is serialized. Declare
→ BookOutorresponse_model=for any model that holds secrets. -
PlainValidatorhides the type from OpenAPI. Provide a schema or description. -
Serialization of subclasses. Only fields of the declared type are written; use
polymorphic_serializationorserialize_as_anydeliberately.