Skip to content

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.

AnnotationAcceptsNotes
strstringsNot int; numbers are not coerced to text
intints, whole floats, numeric strings1.5 is rejected, 1.0 is not
floatnumbers, numeric strings
boolbools, 0/1, "true", "yes", "on", "n", …
bytesbytes, strings (UTF-8 encoded)
Decimalnumbers and stringsThe right type for money
NoneNone onlyAlmost always part of a union
class Order(BaseModel):
quantity: int
total: Decimal
paid: bool

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

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.

from datetime import datetime, date, time, timedelta
published_at: datetime
birth_date: date
duration: timedelta

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

from enum import Enum
from pathlib import Path
from uuid import UUID
class Status(str, Enum):
DRAFT = "draft"
PUBLISHED = "published"
class PostCreate(BaseModel):
id: UUID
status: Status
attachment: Path

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

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

author_id: int | None = None
identifier: 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 null
x: int | None = None # optional, defaults to null

For a union of several models, use a discriminated union. It is faster and produces far better errors than trying each in turn.

from pydantic import (
EmailStr, HttpUrl, AnyUrl, IPvAnyAddress, Json, SecretStr, SecretBytes,
PositiveInt, NonNegativeInt, NegativeInt, PositiveFloat,
conint, confloat, constr, condecimal, conlist,
)
TypeValidates
EmailStrAn email address. Needs email-validator.
HttpUrlAn http/https URL, and normalises it
AnyUrlAny URL with a scheme
IPvAnyAddressAn IPv4 or IPv6 address
JsonA string containing JSON, parsed
SecretStrA string that does not appear in repr or logs
PositiveInt, NonNegativeInt, …Sign constraints
class SignUp(BaseModel):
email: EmailStr
website: HttpUrl | None = None
password: SecretStr

EmailStr needs a dependency the starters already carry:

Terminal window
uv add email-validator

SecretStr 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 greppable

The con* constructors are the older way to attach constraints. Prefer Field() or Annotated, which read better and compose:

from typing import Annotated
from annotated_types import Len
tags: Annotated[list[str], Len(min_length=1, max_length=10)]

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=True
M(n="abc") # ValidationError
M(n=1.5) # ValidationError — would lose information
M(n=1.0) # n=1 — lossless

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

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 int

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

metadata: Any # accepted unvalidated

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

For a value with its own rules, Annotated plus a validator:

from typing import Annotated
from 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: Slug

Slug is now reusable across every model, and the rule lives in one place. See Validators.