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:

Table 1. Who does what in a request
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 Content-Type, decodes JSON, calls Pydantic, collects all errors into one RequestValidationError (422), calls your function, validates the return value against the response model and generates the OpenAPI document.

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())

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 = None is optional and nullable. name: str | None without a default is required (the client must send the key, possibly as null) — 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, but Field(default_factory=list) states the intent explicitly and is required for defaults that must be computed per instance (for example default_factory=datetime.now).

  • Models are keyword-only. Author("Ursula") is a TypeError; write Author(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_set remembers what was provided. This is how exclude_unset=True and PATCH-style updates work (see Serialization).

  • Models validate on construction only. Assigning a.name = 5 later does not validate unless you enable validate_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:

Table 2. Arguments of Field()
Group Arguments Effect

Defaults

default, default_factory, validate_default

The value used when the key is missing. A default is not validated unless validate_default=True.

Numbers

gt, ge, lt, le, multiple_of, allow_inf_nan, max_digits, decimal_places

Bounds and shape of int, float and Decimal values. max_digits and decimal_places apply to Decimal.

Sizes

min_length, max_length

Length of str, bytes, list, set, tuple, dict and other sized values. (v1’s min_items and max_items are gone.)

Strings

pattern

A regular expression the string must match (v1’s regex).

Strictness

strict, coerce_numbers_to_str

Per-field switch between strict and lax coercion, and permission to accept numbers for str fields (see Strict vs Lax Validation).

Unions

union_mode, discriminator

smart (default) or left_to_right matching, and discriminated unions on a tag field.

Names

alias, validation_alias, serialization_alias, alias_priority

The external name of the field (see Computed Fields and Aliases).

Documentation

title, description, examples, deprecated, json_schema_extra, field_title_generator

Metadata copied into the JSON Schema and so into OpenAPI and Swagger UI.

Behaviour

frozen, exclude, exclude_if, repr, init, init_var, kw_only, fail_fast

frozen makes the field read-only; exclude and exclude_if drop it from serialization; repr controls whether it appears in the model’s repr(); fail_fast stops validating a sequence at its first bad item.

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])

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 type that identifies the rule (string_pattern_mismatch, string_too_short, decimal_max_places, too_short, less_than_equal) and a human-readable msg.

  • The constraints surface in OpenAPI as pattern, minLength, maxLength, minimum/maximum and exclusiveMinimum; description and examples appear in Swagger UI next to the field.

  • The model appears twice in components.schemas, as Book-Input and Book-Output. FastAPI generates separate schemas for the request and the response when the two differ; here the Decimal field is accepted as a number or a numeric string but is serialized as a string. The behaviour and the separate_input_output_schemas=False switch 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), not min_items=1). The Annotated spelling replaces them.

  • Constraints do not reach inside containers. isbns: list[str] = Field(pattern=…​) is an error in v2, because pattern applies 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:

Table 3. Validator mode: when it runs and what it receives
Mode Runs Receives

after (default)

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

Before Pydantic’s validation

The raw input, of any type (a str, a dict, None…​). Use it to normalise input, so check isinstance.

wrap

Around Pydantic’s validation

The raw input and a handler that runs the inner validation; you can catch its error or post-process its result.

plain

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])

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"])

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 error type is value_error and msg starts with Value error, `) or `AssertionError (assertion_error). Asserts are removed when Python runs with -O, so prefer ValueError for real rules.

  • Do not raise HTTPException from 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 the 422 response.

  • To control the error type, msg and ctx yourself, raise pydantic_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"])

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:

Table 4. Frequently used ConfigDict options
Option Values / default Effect

extra

"ignore" (default), "forbid", "allow"

What to do with keys that are not fields: drop them, reject them with an extra_forbidden error, or keep them (model_extra). Use "forbid" on request models to catch typos and on query/header/cookie/form models to stop unexpected parameters.

from_attributes

False

Allows validation from arbitrary objects through attribute access (an ORM row, a dataclass) instead of dicts. This is v1’s orm_mode, and it is what lets a path function return an ORM object for a response model.

str_strip_whitespace, str_to_lower, str_to_upper

False

Normalise every str field of the model.

str_min_length, str_max_length

unset

Length bounds for every str field.

frozen

False

Instances become immutable and hashable; assignment raises a frozen_instance error.

validate_assignment

False

Re-validate a field on assignment (a.stars = 9).

validate_default

False

Validate default values too (v1’s always=True).

strict

False

Strict mode for every field of the model (see Strict vs Lax Validation).

populate_by_name, validate_by_name, validate_by_alias, serialize_by_alias, alias_generator, loc_by_alias

see Computed Fields and Aliases

How aliases are used on input, output and in error locations.

use_enum_values

False

Store the enum’s value instead of the member.

coerce_numbers_to_str

False

Accept int, float and Decimal for str fields.

arbitrary_types_allowed

False

Allow classes Pydantic has no schema for, validated with isinstance.

revalidate_instances

"never" (default)

Whether instances passed in as field values are validated again.

json_schema_extra, title, json_schema_serialization_defaults_required

-

Customise the JSON Schema, hence OpenAPI.

protected_namespaces

("model_validate", "model_dump")

Field-name prefixes reserved for Pydantic’s own methods; a clash raises a warning.

ser_json_timedelta, ser_json_bytes, val_json_bytes, ser_json_inf_nan, ser_json_temporal

-

Format of timedeltas, bytes and so on in JSON.

defer_build, cache_strings, regex_engine

-

Start-up and performance switches.

polymorphic_serialization

False

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)

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 adds additionalProperties: false to 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 as titel.

  • from_attributes=True and responses. A path function annotated → BookOut may return an object whose attributes match; FastAPI validates it against BookOut and serializes only the model’s fields. This is the standard way to hide database columns such as hashed_password (see Response Models & Return Types and SQL Databases with SQLModel).

  • frozen=True is shallow. A frozen model cannot be assigned to, but a list field 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 with model_validate or model_dump are protected. A field called model_name is fine; override protected_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=True on the decorator), whereas plain model_dump() uses field names unless you pass by_alias=True. Pydantic 2.11 added the serialize_by_alias setting; its default is False and is expected to become True in Pydantic v3.

  • Since Pydantic 2.11 validate_by_name=True together with validate_by_alias=True is the current spelling of v1’s allow_population_by_field_name (populate_by_name still works); setting both to False is an error.

  • The computed field price is absent from Book-Input and present and required in Book-Output, so the OpenAPI document correctly tells a client it can rely on price in responses but must not send it.

  • Aliases are what errors report in loc: a missing pageCount is located at ["body", "pageCount"], unless you set loc_by_alias=False.

  • Aliases that are not valid Python identifiers ("content-type") are the reason Field(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:

Table 5. Serialization methods of BaseModel
Call Result

model_dump()

A dict in Python mode: values keep their Python types (datetime, Decimal, UUID, SecretStr…​).

model_dump(mode="json")

A dict containing only JSON-compatible types (strings for dates, decimals and UUIDs).

model_dump_json()

A JSON str, produced by the Rust serializer; accepts indent. This replaces v1’s .json().

include=, exclude=

Sets (or nested dicts) of field names to keep or drop.

exclude_unset=True

Drop fields that were not provided at construction. Basis of PATCH updates.

exclude_defaults=True

Drop fields whose value equals their default.

exclude_none=True

Drop fields whose value is None.

by_alias=True

Use aliases (serialization_alias where set) as keys.

serialize_as_any=True

Serialize nested values by their actual runtime class instead of the annotated type.

model_copy(update=…​, deep=…​)

A copy with some fields replaced. The update values are not validated.

Model.model_json_schema(mode="serialization")

The JSON Schema of the output shape ("validation" is the input 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())

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())

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 an int field 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())

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))

Selected results of that run:

Table 6. Lax and strict validation of the same input (Python mode)
Target type Input Lax Strict

int

"42"

42

int_type error

int

42.0

42

int_type error

int

42.5

int_from_float error

int_type error

int

True

1

int_type error

float

3

3.0

3.0 (an int is always a valid float)

bool

"yes", "off", 1

True, False, True

bool_type error

bool

2, "maybe"

bool_parsing error

bool_type error

str

5

string_type error

string_type error

str

b"abc"

"abc"

string_type error

date

"2026-10-02"

date(2026, 10, 2)

date_type error (accepted in strict JSON mode)

date

1700000000

date_from_datetime_inexact error

date_type error

Decimal

9.99

Decimal("9.99")

is_instance_of error

list[int]

(1, "2")

[1, 2]

list_type error

list[int]

"123"

list_type error

list_type error

dict[str, int]

[("a", 1)]

dict_type error

dict_type error

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())

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': {}}]}
Table 7. Fields of each error object
Field Meaning

type

A stable machine-readable code (missing, int_parsing, less_than_equal, string_too_short, string_pattern_mismatch, extra_forbidden, value_error, json_invalid…​). Build client logic on this, not on msg.

loc

Where the problem is: the source (body, query, path, header, cookie) followed by the field name (or alias) and, for containers, the index or key (["body", "tags", 1]). A whole-body error has ["body"] only. When the function has several body parameters, the parameter name is the second element (["body", "s", "username"]).

msg

A human-readable English message. It is not stable across versions and is not translated.

input

The offending value as received. This can echo sensitive data back to the client; see the pitfall about input below.

ctx

Optional values of the failed rule ({"le": 5}, {"min_length": 3}) or, for validators raising ValueError, {"error": …​} (see Validators).

The error type values are catalogued in Pydantic’s validation errors reference. Notes:

  • A malformed JSON body gives one error of type json_invalid with loc ["body", <character position>], and a body that is JSON but not an object gives model_attributes_type at ["body"].

  • Pydantic’s own error dictionaries also carry a url pointing 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())

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])

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:

Table 8. From Pydantic feature to OpenAPI
Pydantic OpenAPI / Swagger UI

Required field (no default)

Listed in required.

Field(ge=, le=, min_length=, pattern=…​)

minimum, maximum, exclusiveMinimum, minLength, maxLength, pattern.

description, title, examples, deprecated=True

Text, example values and a strike-through field in Swagger UI.

json_schema_extra (field or model)

Any extra keywords, for example a model-level examples list.

extra="forbid"

additionalProperties: false.

@computed_field

A readOnly property, required in the output schema only.

Aliases

The alias is the property name; serialization_alias shows in the output schema, validation_alias in the input schema.

Validators (field_validator, model_validator)

Nothing: arbitrary Python cannot be described. Document the rule in description.

PlainValidator

The field’s schema becomes empty ({"title": "V"}) because Pydantic cannot know the accepted type.

Decimal, bytes, UUID, datetime

format or pattern entries; Decimal produces different input and output schemas (Book-Input, Book-Output).

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"])

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?

flowchart LR raw["Raw JSON body
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))

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

Table 9. FastAPI and Pydantic v1
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 pydantic.v1 from inside Pydantic v2, so that an app can migrate model by model.

0.126.0

2025-12-20

Drops support for Pydantic v1 (temporarily still supports pydantic.v1).

0.128.0

2025-12-27

Drops support for pydantic.v1 as well: the latest FastAPI versions require Pydantic v2 (declared as pydantic>=2.9.0).

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).

Table 10. Pydantic v1 to v2 names
Pydantic v1 Pydantic v2 Notes (verified behaviour in 2.13.5)

Methods of BaseModel

.dict()

model_dump()

The v1 name still works but emits PydanticDeprecatedSince20.

.json()

model_dump_json()

.json() is deprecated; arguments such as indent or ensure_ascii may give confusing errors. v2 accepts indent.

Model.parse_obj(d)

Model.model_validate(d)

Deprecated alias works.

Model.parse_raw(s)

Model.model_validate_json(s)

Deprecated alias works. parse_file is deprecated too; load the data yourself, then validate.

Model.from_orm(obj)

Model.model_validate(obj) with from_attributes=True

from_orm raises PydanticUserError unless the config sets from_attributes.

.copy(update=…​)

model_copy(update=…​, deep=…​)

The update is not validated in either version.

Model.construct(…​)

Model.model_construct(…​)

Builds without validating, so bad values go in silently.

Model.schema()

Model.model_json_schema()

Deprecated alias works.

Model.schema_json()

Model.model_json_schema() + json.dumps

Deprecated alias works.

Model.update_forward_refs()

Model.model_rebuild()

Deprecated alias works.

Model.validate(d)

Model.model_validate(d)

Deprecated alias works.

fields

model_fields

Maps names to FieldInfo. Deprecated alias works. Access on the class, not on the instance, in new code.

fields_set

model_fields_set

Deprecated alias works.

parse_obj_as(T, x)

TypeAdapter(T).validate_python(x)

pydantic.tools.parse_obj_as still works with a deprecation warning.

schema_of(T)

TypeAdapter(T).json_schema()

Validates and documents types that are not models, such as list[int].

Model.root field

RootModel[T]

A field called root is a TypeError; the value is .root.

GenericModel

BaseModel + Generic[T]

pydantic.generics.GenericModel still imports, deprecated.

@validate_arguments

@validate_call

Renamed.

model equality with a dict

not equal

In v2 a model is only equal to another model.

class Config and settings

class Config:

model_config = ConfigDict(…​)

The inner class still works but warns. Several keys were removed (see the last rows of this group).

orm_mode = True

from_attributes=True

Renamed.

allow_population_by_field_name

populate_by_name, or validate_by_name (from 2.11)

Renamed.

anystr_strip_whitespace

str_strip_whitespace

Renamed.

anystr_lower / anystr_upper

str_to_lower / str_to_upper

Renamed.

min_anystr_length / max_anystr_length

str_min_length / str_max_length

Renamed.

schema_extra

json_schema_extra

Renamed.

validate_all

validate_default

Renamed.

allow_mutation = False

frozen=True

The same rename exists on Field().

keep_untouched

ignored_types

Renamed.

json_encoders

@field_serializer / @model_serializer

Deprecated (performance, complexity); prefer the serializer decorators.

copy_on_model_validation, post_init_call, json_loads, json_dumps, underscore_attrs_are_private

no equivalent

Removed (the underscore behaviour is always on).

from pydantic import BaseSettings

from pydantic_settings import BaseSettings

PydanticImportError; install pydantic-settings (included in fastapi[standard]); see Settings, Lifespan & Background Tasks.

Fields and constraints

Field(…​) for required

no default

Ellipsis still means required, but the idiom is to write no default.

regex=

pattern=

Field(regex=…​) raises PydanticUserError. FastAPI’s Query(regex=…​) still works with a FastAPIDeprecationWarning.

min_items= / max_items=

min_length= / max_length=

Deprecated in Field; an error in conlist.

unique_items=True

a set type

Field(unique_items=…​) raises PydanticUserError.

const=True

Literal[…​]

Field(const=…​) raises PydanticUserError.

Field(example=…​), Query(example=…​), Body(example=…​)

examples=[…​] (or json_schema_extra)

Extra keyword arguments on Field are deprecated; FastAPI’s own example warns with FastAPIDeprecationWarning.

conint(ge=1), constr(regex=, to_upper=)

Annotated[int, Field(ge=1)], Annotated[str, StringConstraints(pattern=…​, to_upper=True)]

The con* functions still exist; constr takes pattern.

Optional[X] without default

required, nullable

In v1 it was optional with default None. Write X | None = None.

Field(…​) on list[str] applied to items

list[Annotated[str, Field(…​)]]

Constraints are no longer pushed down into containers.

ConstrainedInt classes, pydantic.typing

removed

Use Annotated.

field.alias returns the name when unset

returns None

Changed in FieldInfo.

Validators

@validator("x")

@field_validator("x")

@validator still works but warns; the field and config arguments are gone (use ValidationInfo).

@validator("x", pre=True)

@field_validator("x", mode="before")

Add @classmethod.

@validator("x", each_item=True)

annotate the item type: list[Annotated[int, Field(ge=0)]]

No each_item in v2.

@validator("x", always=True)

Field(validate_default=True)

In v2 type validation also runs on defaults, not only your function.

@root_validator(pre=True)

@model_validator(mode="before")

-

@root_validator(skip_on_failure=True)

@model_validator(mode="after")

v1’s values dict becomes self. A bare @root_validator without skip_on_failure is an error.

allow_reuse=True

delete it

No longer necessary.

Errors and imports

pydantic.error_wrappers.ValidationError

pydantic.ValidationError

Old path still imports with a deprecation warning.

type_error.integer, value_error.missing, value_error.any_str.min_length

int_type / int_parsing, missing, string_too_short

New type names, new msg text; ctx.limit_value became the rule name (ctx.le).

EmailStr, NameEmail

same, still needs email-validator

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]))

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)"))

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))

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 answers 422 missing for that key (the required_nullable line of the output). Add = None.

  • No more number-to-string coercion. Num(s=5) is string_type in v2, where v1 produced "5". Opt back in with coerce_numbers_to_str=True (model or field), or fix the client.

  • Smart unions. str | int and int | str both keep "1" as a string in v2 because the input already matches a member; v1 tried members from left to right and converted. Use Field(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 needs Field(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_obj copies input and construct/model_construct do not validate.

  • Dataclasses. Tuples are no longer accepted as input for dataclass fields, post_init runs after validation, extra="allow" is unsupported and pydantic_model is gone (wrap the dataclass in a TypeAdapter for validation and schemas).

  • JSON Schema of Optional fields now says null is allowed (anyOf with {"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

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:

  1. Make sure the application has tests and runs them in CI.

  2. Run bump-pydantic and review the diff.

  3. Run the tests, fix what remains by hand, using the tables above.

  4. 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.missing error 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 be None.

  • pydantic.v1 is 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, Header and Cookie parameters and 0.113.0 (2024-09-05) for Form; both accept extra="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 (examples instead of example).

  • 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 695 type aliases) in annotations, so reusable constraint aliases can use the type statement on Python 3.12 and later.

  • Pydantic 2.11 (2025-03-27) introduced validate_by_name, validate_by_alias and serialize_by_alias.

  • The migration of the book’s era is summarised for the whole framework in What’s Changed & Migration.

Pitfalls

  • Optional is not "optional". str | None without = None is required. The OpenAPI required list and the 422 missing error show it.

  • Validators raise ValueError, not HTTPException. Validators run outside the HTTP layer; map errors to HTTP at the boundary.

  • ctx.error is empty. Exceptions raised in validators appear as {} in ctx; put the information in the message.

  • Echoed input. 422 bodies repeat input, including passwords and tokens. hide_input_in_errors=True in model_config does not hide it in FastAPI’s response (verified above); strip input (and ctx) in a RequestValidationError handler.

  • Strict query parameters. Query(strict=True) rejects everything, because query values are strings.

  • Constraints on containers. Field(pattern=…​) on a list[str] does not constrain the items; use list[Annotated[str, Field(…​)]].

  • Default values are not validated. x: int = "7" stays the string "7" unless validate_default=True.

  • Mutable models. A model is mutable and not validated on assignment by default; frozen=True prevents assignment but does not freeze nested lists.

  • model_construct and model_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.data only contains fields declared earlier and fields that passed validation, so a failed password leaves no info.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 → BookOut or response_model= for any model that holds secrets.

  • PlainValidator hides the type from OpenAPI. Provide a schema or description.

  • Serialization of subclasses. Only fields of the declared type are written; use polymorphic_serialization or serialize_as_any deliberately.