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 pydantic

A 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 string

Note 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 errors

Use 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: PositiveInt

Custom 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 v

A 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 self

Nested 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)   # Arlington

model_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 values

If 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-settings
from 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 .env

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