What Pydantic understands (scalars, collections, dates, enums, unions and the library's own constrained types) plus the coercion rules and strict mode.
An annotation is a contract about the value after validation. Pydantic coerces where it can and rejects where it cannot.
Scalars
Section titled “Scalars”| Annotation | Accepts | Notes |
|---|---|---|
str | strings | Not int; numbers are not coerced to text |
int | ints, whole floats, numeric strings | 1.5 is rejected, 1.0 is not |
float | numbers, numeric strings | |
bool | bools, 0/1, "true", "yes", "on", "n", … | |
bytes | bytes, strings (UTF-8 encoded) | |
Decimal | numbers and strings | The right type for money |
None | None only | Almost always part of a union |
class Order(BaseModel): quantity: int total: Decimal paid: boolUse Decimal for money. float is binary floating point. 0.1 + 0.2 is
not 0.3, and an invoice built on it will not add up. It pairs with
DecimalField on the ORM side.
Collections
Section titled “Collections”tags: list[str]scores: dict[str, int]coordinates: tuple[float, float]unique_ids: set[int]The parameter is validated too, item by item. list[str] rejects a list
containing an int, naming the index that failed.
list unparameterised accepts anything and validates nothing. It is almost
never what you want in an API model, because it becomes an untyped array in
your OpenAPI schema.
Dates and times
Section titled “Dates and times”from datetime import datetime, date, time, timedelta
published_at: datetimebirth_date: dateduration: timedeltadatetime accepts an ISO 8601 string, a datetime, or a Unix timestamp as
int or float. "2026-08-15T10:30:00Z" parses, and so does 1786000000.
timedelta accepts a number of seconds or an ISO 8601 duration.
UUID, paths, enums
Section titled “UUID, paths, enums”from enum import Enumfrom pathlib import Pathfrom uuid import UUID
class Status(str, Enum): DRAFT = "draft" PUBLISHED = "published"
class PostCreate(BaseModel): id: UUID status: Status attachment: PathAn enum annotation accepts a member or its value, and produces a member. In
OpenAPI it becomes an enum with the allowed values listed, so the
documentation shows exactly what a client may send.
Inheriting str makes the member JSON-serialisable and comparable to a plain
string, worth doing for anything that crosses the wire. It also lines up with
CharEnumField on the model.
Literal
Section titled “Literal”from typing import Literal
sort: Literal["created_at", "title", "views"] = "created_at"An inline enumeration. For a small fixed set that does not deserve a class (a sort key, a mode flag) this is the shortest way to get validation and a documented list of options.
It is also how you replace v1’s const=.
Unions and optionals
Section titled “Unions and optionals”author_id: int | None = Noneidentifier: int | str| None does not make a field optional in v2. It allows None as a value;
the field is still required unless it has a default. This is the single most
common v1-to-v2 surprise. See Models.
x: int | None # required, may be nullx: int | None = None # optional, defaults to nullFor a union of several models, use a discriminated union. It is faster and produces far better errors than trying each in turn.
Pydantic’s own types
Section titled “Pydantic’s own types”from pydantic import ( EmailStr, HttpUrl, AnyUrl, IPvAnyAddress, Json, SecretStr, SecretBytes, PositiveInt, NonNegativeInt, NegativeInt, PositiveFloat, conint, confloat, constr, condecimal, conlist,)| Type | Validates |
|---|---|
EmailStr | An email address. Needs email-validator. |
HttpUrl | An http/https URL, and normalises it |
AnyUrl | Any URL with a scheme |
IPvAnyAddress | An IPv4 or IPv6 address |
Json | A string containing JSON, parsed |
SecretStr | A string that does not appear in repr or logs |
PositiveInt, NonNegativeInt, … | Sign constraints |
class SignUp(BaseModel): email: EmailStr website: HttpUrl | None = None password: SecretStrEmailStr needs a dependency the starters already carry:
uv add email-validatorSecretStr is worth reaching for on anything sensitive. It keeps the value out
of repr(), out of tracebacks, and out of a model_dump() unless you ask,
which is the difference between a password appearing in an error report and
not.
password.get_secret_value() # explicit, and greppableThe con* constructors are the older way to attach constraints. Prefer
Field() or Annotated, which read better and compose:
from typing import Annotatedfrom annotated_types import Len
tags: Annotated[list[str], Len(min_length=1, max_length=10)]Coercion
Section titled “Coercion”Pydantic v2 is in lax mode by default: it converts where the conversion is unambiguous and safe.
class M(BaseModel): n: int flag: bool
M(n="42", flag="yes") # n=42, flag=TrueM(n="abc") # ValidationErrorM(n=1.5) # ValidationError — would lose informationM(n=1.0) # n=1 — losslessThat behaviour is what makes query parameters work at all: everything arriving
in a URL is a string, and Query(type=int) needs "5" to become 5.
Strict mode
Section titled “Strict mode”To turn coercion off:
from pydantic import ConfigDict
class M(BaseModel): model_config = ConfigDict(strict=True)
n: int
M(n="42") # ValidationError — a string is not an intPer field:
from pydantic import Field
n: int = Field(strict=True)Or on a parameter marker:
count = Query(type=int, strict=True)Strict is right for a JSON body, where the client controls the types and
sending "42" for a number is a client bug worth surfacing. It is wrong for
query parameters, headers and form fields, which are strings by definition.
Strict mode there rejects every input.
Any and no annotation
Section titled “Any and no annotation”metadata: Any # accepted unvalidatedAny accepts anything and validates nothing. Occasionally correct (a webhook
payload you store verbatim) and usually a sign that the shape has not been
decided yet.
In OpenAPI it becomes an empty schema, so a client generator produces
unknown. If the shape is known, declare it.
Custom types
Section titled “Custom types”For a value with its own rules, Annotated plus a validator:
from typing import Annotatedfrom pydantic import AfterValidator
def check_slug(value: str) -> str: if not re.fullmatch(r"[a-z0-9-]+", value): raise ValueError("must be lowercase letters, digits and hyphens") return value
Slug = Annotated[str, AfterValidator(check_slug)]
class PostCreate(BaseModel): slug: SlugSlug is now reusable across every model, and the rule lives in one place. See
Validators.