Pydantic is the de-facto standard for turning untrusted input into trusted, typed Python objects. You declare a schema as an ordinary class whose annotated attributes (id: int, name: str) become validated fields, and pydantic does the rest: it coerces sensible values, enforces your constraints, and raises a single ValidationError listing every problem when the input is wrong. The recurring mental model in this sheet is one picture: a raw { } dict or JSON string on the left flows through a pink validator gate (your model) and emerges as either a green typed instance or a red ValidationError. Where this looks like the requests sheet, the contrast is the point: requests fetches JSON over the wire; pydantic validates and shapes that JSON into objects you can trust. The conventional import is from pydantic import BaseModel, Field, ValidationError, and everything here is pydantic v2 (v1 spellings are flagged per section).
Declare a BaseModel
A pydantic model is just a class that subclasses BaseModel, where each annotated attribute becomes a validated field. Defaults and None-able types are written the normal Python way (name: str = "anon", nickname: str | None = None), you construct instances with keywords, and the result is a typed object whose attributes are guaranteed to match their annotations. User.model_fields lets you introspect the schema you declared.
from pydantic import BaseModel
class User(BaseModel):
id: int
name: str = "anon" # field with a default
nickname: str | None = None # nullable, defaults to None
u = User(id=1, name="ada") # construct from keywords
User.model_fields # introspect the declared fields
u.id # 1 (a real int)See Models. The v1 inner class Config: is replaced by model_config = ConfigDict(...).
Validate Input
The job of a model is to turn untrusted input into a trusted object. User.model_validate(data) validates a Python dict and User.model_validate_json(text) parses and validates raw JSON in one step. Bad input never passes silently: it raises a ValidationError. TypeAdapter extends the same validation to non-model types like list[User], and strict=True forbids type coercion.
User.model_validate({"id": 1, "name": "ada"}) # validate a Python dict
User.model_validate_json('{"id": 1, "name": "ada"}') # parse + validate JSON
User(id=1, name="ada") # construct from keywords
from pydantic import TypeAdapter
TypeAdapter(list[User]).validate_python(rows) # validate non-model types
User.model_validate({"id": "oops"}) # -> raises ValidationError
User.model_validate({"id": "1"}, strict=True) # strict: no str -> int coercionSee Validating data. model_validate / model_validate_json replace v1 parse_obj / parse_raw.
Coerce and Constrain
By default pydantic coerces sensible values (the string "30" becomes the int 30), and Field() layers on constraints (ge, le, min_length, max_length, pattern) plus metadata like defaults and aliases. Use default_factory=list for mutable defaults so each instance gets its own fresh container, and Annotated[type, Field(...)] to attach constraints without losing the type.
from typing import Annotated
from pydantic import Field, AliasChoices
age: int # "30" coerces to 30
age: int = Field(ge=0, le=120) # numeric bounds
name: str = Field(min_length=1, max_length=50) # string length
tags: list[str] = Field(default_factory=list) # safe mutable default
zip: Annotated[str, Field(pattern=r"^\d{5}$")] # regex via Annotated
Field(validation_alias=AliasChoices("user_id", "userId")) # accept either keySee Fields. The v1 Field(regex=...) is now Field(pattern=...).
Read a ValidationError
When validation fails, pydantic does not stop at the first problem: it collects every error into one ValidationError. e.errors() returns a list of dicts with loc (the field path), type, msg, and input, and e.error_count() tells you how many there were. Catch it with except ValidationError as e, print it for humans, or call e.json() to ship a machine-readable report back to a caller.
from pydantic import ValidationError
try:
User.model_validate({"id": "oops"})
except ValidationError as e:
e.error_count() # how many problems were found
e.errors() # [{'loc': ..., 'type': ..., 'msg': ..., 'input': ...}]
e.errors()[0]["loc"] # ('id',) -> the offending field
print(e) # multiline, human-readable summary
e.json() # machine-readable report for a clientSee Validation errors. Each error dict carries loc, type, msg, and input.
Field and Model Validators
When the built-in constraints are not enough, @field_validator("name") runs your own rule on a single field (wrap a classmethod), while @model_validator(mode="after") runs across the whole instance for cross-field rules like “passwords must match”. Use mode="before" to reshape raw input ahead of type coercion, @computed_field to expose a derived read-only property in the output, and raise ValueError(...) inside any validator to reject a value into the normal error report.
from pydantic import field_validator, model_validator, computed_field
@field_validator("name")
@classmethod
def normalize(cls, v: str) -> str:
return v.strip().lower() # per-field rule
@model_validator(mode="after")
def passwords_match(self):
if self.password != self.password2:
raise ValueError("passwords do not match") # cross-field rule
return self
@computed_field # derived, read-only, included in the dump
@property
def area(self) -> int:
return self.width * self.heightSee Validators. @field_validator / @model_validator replace v1 @validator / @root_validator.
Nest and Reuse Models
Models compose like any other type: annotate a field as another model (addresses: list[Address]) and pydantic validates the nested dicts all the way down into real sub-objects. Unions (int | str) accept any listed member, a discriminator makes tagged unions fast and unambiguous, inheritance reuses fields across models, and model_rebuild() resolves forward references for recursive structures.
from typing import Literal, Union
from pydantic import Field
class Person(BaseModel):
addresses: list[Address] # nest one model in another
pet: Union[Cat, Dog] = Field(discriminator="kind") # tagged union, routed by "kind"
value: int | str # union: tries each member
class Admin(User): # inherit and extend
level: int
children: list["Node"] # recursive; then Node.model_rebuild()See Unions and the models guide.
Serialize Out
Going the other way, model_dump() produces a Python dict (keeping rich types like datetime) and model_dump_json() produces a JSON string (rendering those types as strings); mode="json" gives you JSON-safe values inside a dict. Trim payloads with exclude_none=True, hide sensitive fields with Field(exclude=True), and emit a standards-compliant schema for docs or OpenAPI with model_json_schema().
u.model_dump() # -> dict (keeps real datetime, etc.)
u.model_dump_json() # -> JSON string (datetimes -> ISO)
u.model_dump(mode="json") # -> dict with JSON-safe values
u.model_dump(exclude_none=True) # drop None fields, slimmer payload
secret: str = Field(exclude=True) # never serialized
User.model_json_schema() # emit JSON Schema (for OpenAPI / docs)See Serialization. model_dump / model_dump_json replace v1 .dict() / .json().
Config with pydantic-settings
Application config is just another model: BaseSettings (from the separate pydantic-settings package in v2) reads typed values from environment variables and .env files, coercing and validating them exactly like a BaseModel. Configure the source with SettingsConfigDict(env_prefix=..., env_file=...), rely on the source-precedence ladder (init args beat env vars beat .env beat defaults), and let a bad value fail fast at startup with a ValidationError.
# uv add pydantic-settings
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="APP_", env_file=".env")
port: int = 8000
database_url: str
settings = Settings() # reads env (APP_PORT=9000), then .env, then defaults
settings.port # 9000 (coerced str -> int)
# APP_PORT=oops -> ValidationError at startup, not deep in productionSee Pydantic Settings. In v2 BaseSettings lives in the separate pydantic-settings package.
Quick Reference
| Command | What it does | Area |
|---|---|---|
class M(BaseModel): x: int |
Declare a model from type hints | Model |
M.model_fields |
Introspect declared fields | Model |
M.model_validate(data) |
Validate a Python dict | Validate |
M.model_validate_json(text) |
Parse + validate JSON | Validate |
M.model_validate(data, strict=True) |
Validate without coercion | Validate |
TypeAdapter(list[M]).validate_python(rows) |
Validate non-model types | Validate |
Field(ge=0, le=120) |
Numeric bounds | Constrain |
Field(min_length=1, pattern=r"…") |
String length / regex | Constrain |
Field(default_factory=list) |
Safe mutable default | Constrain |
@field_validator("x") |
Custom per-field rule | Validators |
@model_validator(mode="after") |
Cross-field rule | Validators |
@computed_field |
Derived read-only field | Validators |
Field(discriminator="kind") |
Tagged union routing | Nesting |
M.model_dump() |
Serialize to a Python dict | Serialize |
M.model_dump_json() |
Serialize to a JSON string | Serialize |
M.model_json_schema() |
Emit JSON Schema | Serialize |
class S(BaseSettings) |
Typed config from env / .env | Settings |
| Member | Type | Meaning |
|---|---|---|
e.error_count() |
int |
How many problems were found |
e.errors() |
list[dict] |
One dict per error |
e.errors()[i]["loc"] |
tuple |
Field path to the bad value |
e.errors()[i]["type"] |
str |
Error code, e.g. int_parsing |
e.errors()[i]["msg"] |
str |
Human-readable message |
e.errors()[i]["input"] |
any | The value that failed |
e.json() |
str |
Machine-readable error report |
| Pydantic v1 (deprecated) | Pydantic v2 (current) |
|---|---|
Model.parse_obj(d) |
Model.model_validate(d) |
Model.parse_raw(s) |
Model.model_validate_json(s) |
m.dict() |
m.model_dump() |
m.json() |
m.model_dump_json() |
Model.schema() |
Model.model_json_schema() |
@validator |
@field_validator (wraps a @classmethod) |
@root_validator |
@model_validator |
class Config: |
model_config = ConfigDict(...) |
Field(regex=...) |
Field(pattern=...) |
from pydantic import BaseSettings |
from pydantic_settings import BaseSettings |
Appendix: Sample Code
The raw-input to model to output mental model
from pydantic import BaseModel, ValidationError
class User(BaseModel):
id: int
name: str = "anon"
active: bool = True
# Coercion: the string "1" becomes the int 1
u = User.model_validate({"id": "1", "name": "ada"})
u.id # 1 (a real int)
u.model_dump() # {'id': 1, 'name': 'ada', 'active': True}
# Bad input never passes silently
try:
User.model_validate({"id": "oops"})
except ValidationError as e:
print(e.error_count()) # 1
print(e.errors()[0]["type"]) # 'int_parsing'Constraints, validators, and a computed field
from typing import Annotated
from pydantic import BaseModel, Field, field_validator, model_validator, computed_field
class Account(BaseModel):
username: Annotated[str, Field(min_length=1, max_length=50)]
age: int = Field(ge=0, le=120)
tags: list[str] = Field(default_factory=list) # fresh list per instance
password: str
password2: str
@field_validator("username")
@classmethod
def normalize(cls, v: str) -> str:
return v.strip().lower()
@model_validator(mode="after")
def passwords_match(self):
if self.password != self.password2:
raise ValueError("passwords do not match")
return self
@computed_field
@property
def adult(self) -> bool:
return self.age >= 18
a = Account(username=" ADA ", age="30", password="x", password2="x")
a.username # 'ada' (validator ran)
a.age # 30 (coerced str -> int)
a.model_dump()["adult"] # True (computed field included)Nested models and a discriminated union
from typing import Literal, Union
from pydantic import BaseModel, Field
class Address(BaseModel):
city: str
zip: str = Field(pattern=r"^\d{5}$")
class Cat(BaseModel):
kind: Literal["cat"]
meows: bool
class Dog(BaseModel):
kind: Literal["dog"]
barks: bool
class Person(BaseModel):
name: str
addresses: list[Address]
pet: Union[Cat, Dog] = Field(discriminator="kind")
p = Person.model_validate({
"name": "ada",
"addresses": [{"city": "NYC", "zip": "10001"}],
"pet": {"kind": "dog", "barks": True},
})
type(p.addresses[0]).__name__ # 'Address' (real nested instance)
type(p.pet).__name__ # 'Dog' (routed by discriminator)Typed application config (pydantic-settings)
The pattern to copy for any real app: one typed Settings class that fails fast at startup if the environment is wrong.
# uv add pydantic-settings
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="APP_", env_file=".env")
debug: bool = False
port: int = 8000
database_url: str
# With APP_PORT=9000 and APP_DEBUG=true in the environment:
settings = Settings() # reads env vars, then .env, then defaults
settings.port # 9000 (coerced str -> int)
settings.debug # True
# A bad value (APP_PORT=oops) raises ValidationError at startup, not in prod.Behavior notes
- Coercion is on by default, strict is opt-in.
model_validate({"id": "1"})happily coerces"1"to1; passstrict=True(or use strict types) when an exact type match matters. @field_validatormust wrap a@classmethod. The decorator order matters:@field_validatoron top,@classmethodbeneath, then the function that takes(cls, v).- Mutable defaults need
default_factory. Writingtags: list[str] = []would share one list across instances;Field(default_factory=list)mints a fresh one each time. model_dump()keeps Python types;model_dump_json()stringifies them. Adatetimestays adatetimein the dict but becomes an ISO string in JSON; usemode="json"for JSON-safe values in a dict.- v1 spellings still work but warn.
.dict(),.json(),parse_obj,@validator, and friends emit deprecation warnings in v2; prefer themodel_*names and the new decorators. BaseSettingsmoved out of core. In v2 it lives inpydantic-settings(uv add pydantic-settings); importing it frompydanticis a v1 habit.
References
Pydantic documentation (v2)
- Documentation home and Why use Pydantic
- Models, Validating data, Fields
- Validation errors, Validators, Unions
- Serialization, Pydantic Settings, the v1 to v2 migration guide
Project and related