Validate Everything: A Practical Introduction to Pydantic v2
Learn how Pydantic v2 turns type hints into runtime validation: models, custom validators, nested data, serialization, and settings management — with runnable examples and common pitfalls.
Type hints are one of modern Python's best features — but on their own, they are just documentation. Nothing stops a caller from passing "42" where an int is expected, or a dict missing half its keys. The moment your program touches the outside world — JSON from an API, rows from a CSV, environment variables, user input — you need runtime validation, and writing it by hand is tedious and error-prone.
That is the problem Pydantic solves. You declare the shape of your data once, using ordinary type hints, and Pydantic validates, coerces, and serializes it for you. Version 2 rewrote the validation core in Rust (pydantic-core), making it dramatically faster than v1 while keeping the declarative style. Pydantic also powers FastAPI's request validation, so learning it pays off twice.
This guide covers the essentials of Pydantic v2: defining models, constraining fields, writing custom validators, handling nested data, serializing back out, and managing application settings.
Getting started
pip install pydanticA model is a class that inherits from BaseModel. Fields are declared with type annotations, exactly like a dataclass:
from pydantic import BaseModel
class User(BaseModel):
id: int
name: str
email: str
is_active: bool = True
user = User(id="123", name="Ada Lovelace", email="ada@example.com")
print(user.id) # 123 (an int — coerced from the string "123")
print(user.is_active) # True (default applied)Two things happened here. First, validation: if id had been "not-a-number", Pydantic would have raised a ValidationError. Second, coercion: "123" was converted to the integer 123. Pydantic's default "smart" mode accepts sensible conversions (numeric strings to ints, "true" to True) but rejects nonsense.
When validation fails, the error tells you exactly which fields are wrong and why:
from pydantic import ValidationError
try:
User(id="oops", name=42, email=None)
except ValidationError as e:
print(e.error_count()) # 3
for err in e.errors():
print(err["loc"], err["msg"])
# ('id',) Input should be a valid integer, unable to parse string as an integer
# ('name',) Input should be a valid string
# ('email',) Input should be a valid stringNote that name=42 failed: smart mode does not silently stringify arbitrary objects. If you want stricter behavior everywhere — no coercion at all — enable strict mode per model with model_config = ConfigDict(strict=True).
Constraining fields with Field
Type checks alone are rarely enough. Field() adds constraints and metadata:
from pydantic import BaseModel, Field
class Product(BaseModel):
name: str = Field(min_length=1, max_length=100)
price: float = Field(gt=0, description="Price in EUR")
quantity: int = Field(ge=0, default=0)
tags: list[str] = Field(default_factory=list)
Product(name="Widget", price=9.99) # fine
Product(name="", price=-1) # ValidationError: 2 errorsUse default_factory for mutable defaults like lists and dicts — the same rule as dataclasses. For common formats, Pydantic ships ready-made types so you don't reinvent regexes:
from pydantic import BaseModel, EmailStr, HttpUrl, PositiveInt
class Signup(BaseModel):
email: EmailStr # requires: pip install "pydantic[email]"
website: HttpUrl
age: PositiveIntCustom validators
When built-in constraints aren't enough, write a validator. In v2 the decorator is @field_validator (replacing v1's @validator):
from pydantic import BaseModel, field_validator
class Booking(BaseModel):
username: str
room: str
@field_validator("username")
@classmethod
def username_must_be_clean(cls, v: str) -> str:
v = v.strip().lower()
if not v.isidentifier():
raise ValueError("must contain only letters, digits, underscores")
return vA field validator receives the value, can transform it, and must return the (possibly modified) value. Raise ValueError to reject it — Pydantic wraps it into a proper ValidationError. By default validators run after Pydantic's own type validation; pass mode="before" to preprocess raw input first.
To validate relationships between fields, use @model_validator:
from datetime import date
from pydantic import BaseModel, model_validator
class Stay(BaseModel):
check_in: date
check_out: date
@model_validator(mode="after")
def check_dates(self):
if self.check_out <= self.check_in:
raise ValueError("check_out must be after check_in")
return selfNested models and real-world JSON
Models compose naturally. This is where Pydantic shines: one call validates an entire nested payload.
from pydantic import BaseModel
class Address(BaseModel):
street: str
city: str
postal_code: str
class Order(BaseModel):
id: int
customer: str
shipping_address: Address
items: list[dict[str, int]]
payload = """
{
"id": 1001,
"customer": "Grace Hopper",
"shipping_address": {"street": "1 Navy Way", "city": "Arlington", "postal_code": "22217"},
"items": [{"widget": 2}, {"gadget": 1}]
}
"""
order = Order.model_validate_json(payload)
print(order.shipping_address.city) # Arlingtonmodel_validate_json() parses and validates JSON in one step (and is faster than json.loads followed by model_validate, because parsing happens in Rust). For already-parsed dicts, use Order.model_validate(data).
Serialization: getting data back out
order.model_dump() # plain dict, Python objects preserved
order.model_dump(mode="json") # dict with JSON-safe values (dates -> strings)
order.model_dump_json(indent=2) # JSON string
order.model_dump(exclude={"items"}) # drop fields
order.model_dump(exclude_none=True) # drop None valuesIf you migrated from v1: .dict() and .json() are deprecated; the v2 names are model_dump() and model_dump_json().
Sometimes you want to validate something that isn't a model — say, a bare list of users from an API. TypeAdapter handles arbitrary types:
from pydantic import TypeAdapter
adapter = TypeAdapter(list[User])
users = adapter.validate_python([
{"id": 1, "name": "Ada", "email": "ada@example.com"},
{"id": 2, "name": "Alan", "email": "alan@example.com"},
])Settings management
A hugely practical use case: typed application config from environment variables, via the companion package pydantic-settings:
pip install pydantic-settingsfrom pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_prefix="APP_")
database_url: str
debug: bool = False
max_connections: int = 10
settings = Settings() # reads APP_DATABASE_URL, APP_DEBUG, ... from env or .envYour app fails fast at startup with a clear error if required config is missing or malformed — far better than a mysterious crash an hour later.
Common pitfalls
Expecting validation on attribute assignment. By default, validation runs at construction time only; user.id = "oops" after creation is not checked. Enable model_config = ConfigDict(validate_assignment=True) if you need it.
Using v1 APIs in v2 code. @validator, .dict(), .parse_obj(), and inner class Config are deprecated. Use @field_validator, model_dump(), model_validate(), and ConfigDict. Old tutorials mix these freely — check the version before copying.
Reaching for Pydantic when a dataclass will do. If your data never crosses a trust boundary — it's created and consumed entirely inside your own code — a plain dataclass is lighter. Pydantic earns its keep at the edges: APIs, files, config, user input.
Forgetting extra-field behavior. By default unknown keys in the input are silently ignored. Set model_config = ConfigDict(extra="forbid") to reject them — a great way to catch typos in config files.
Wrap-up and next steps
Pydantic v2 lets you write the shape of your data once and get validation, coercion, helpful errors, and serialization for free — at Rust speed. Start by modeling one external payload in your current project, turn on extra="forbid" for config, and move env-var handling to pydantic-settings. From there, explore @computed_field for derived values, discriminated unions for polymorphic payloads, and FastAPI, where the models you just learned become request and response schemas automatically. The official docs are excellent for going deeper.