Pydantic v2 Data Validation and Modeling

Reference file — part of the programming-languages hub. Created via /dr research (Pydantic v2 data validation and modeling). Sibling topics in this family are reference files under the hubs (programming-languages, software-engineering-patterns) — not standalone skills. Ignore any “use the X skill” / related_skills / SKIP pointers below that name a bare sibling skill; load that topic’s references/<name>.md from the owning hub (see the hub’s “Cross-hub map”). For general Python idioms, type hints, packaging, and async, see references/python-patterns.md in this hub. For the TypeScript/JS analog (runtime schema validation), see the top-level zod-schema-validation skill.


name: pydantic-v2 description: > Pydantic v2 expert — runtime data validation and modeling in Python powered by the Rust pydantic-core. Covers BaseModel and field definitions (Field, Annotated constraints), the three validator modes (field_validator / model_validator, before/after/wrap/plain), strict vs lax coercion and ConfigDict, serialization (model_dump / model_dump_json, aliases, include/exclude, computed_field, RootModel), TypeAdapter for non-model types, discriminated (tagged) unions, pydantic-settings (BaseSettings, SettingsConfigDict, env/.env/secrets), ValidationError handling, and V1→V2 migration plus performance anti-patterns. TRIGGER: defining or validating Pydantic models; field_validator / model_validator; Annotated constraints; strict mode / type coercion questions; model_dump / serialization / aliases; TypeAdapter; discriminated unions; BaseSettings / config from env; migrating Pydantic V1 → V2; Pydantic validation performance tuning. SKIP: TypeScript/JS runtime validation — use zod-schema-validation; general Python idioms, type hints, packaging, async — use python-patterns.md; pytest/Hypothesis testing — use python-testing.md; API/REST design — use software-engineering-patterns.

Pydantic v2 — Data Validation and Modeling in Python

Overview

Pydantic is the most widely used data-validation library for Python. It validates data at runtime against Python type hints and produces structured, user-friendly errors when data is invalid. Pydantic v2 (released mid-2023, stable and current through 2026) rewrote the validation/serialization engine in Rust as a separate package, pydantic-core (built with PyO3). The result is ~5–50× faster than v1 (≈17× on a typical mixed-field model), with the Python layer reduced to schema definition while the hot path runs in compiled Rust.

Three packages make up the ecosystem:

Use it when you need to parse untrusted input (API bodies, config, JSON, ORM rows) into typed Python objects with guarantees, and serialize them back out.

Core Concepts

1. BaseModel and field definitions

Subclass BaseModel; annotate fields with type hints. Validation runs on construction and on the explicit model_validate* entry points.

from pydantic import BaseModel, Field
from typing import Annotated

class User(BaseModel):
    id: int
    name: str = "anonymous"                     # default
    tags: list[str] = Field(default_factory=list)  # mutable default → factory
    age: Annotated[int, Field(ge=0, le=130)]    # constraint via Annotated

2. Validators — field, model, and the before/after/wrap modes

Pydantic distinguishes validators (input → validated value) from serializers (value → output). Validators run in a defined order around the core (Rust) validation.

from pydantic import BaseModel, field_validator, model_validator, ValidationError
from typing_extensions import Self

class Account(BaseModel):
    username: str
    password: str
    password_confirm: str

    @field_validator("username")          # decorate per-field
    @classmethod                          # field_validator is a classmethod
    def no_spaces(cls, v: str) -> str:
        if " " in v:
            raise ValueError("username must not contain spaces")
        return v.lower()

    @model_validator(mode="after")        # whole-model, cross-field
    def passwords_match(self) -> Self:
        if self.password != self.password_confirm:
            raise ValueError("passwords do not match")
        return self

Modes (the most-confused part of Pydantic v2):

model_validator(mode="before") receives the raw input dict for the whole model; mode="after" receives self (return self). Raise ValueError or AssertionError inside a validator and Pydantic wraps it into a ValidationError. Validators can take an info: ValidationInfo param for info.data (already-validated siblings), info.context, info.field_name.

Reusable validators: attach a validator to a type once with Annotated[str, AfterValidator(func)] / BeforeValidator / WrapValidator / PlainValidator — cleaner than repeating @field_validator across models.

3. Strict vs lax mode and ConfigDict

By default Pydantic is lax: it coerces compatible types ("123"123, "true"True). Strict mode disables coercion and requires exact types.

from pydantic import BaseModel, ConfigDict

class M(BaseModel):
    model_config = ConfigDict(strict=True)   # whole-model strict
    x: int

M.model_validate({"x": "123"})               # raises: str is not a valid int

Strictness is layered (most → least specific): per-call model_validate(..., strict=True)

field-level Field(strict=True) / Strict() annotation > model_config. Common ConfigDict keys:

model_config is a dict (ConfigDict(...)), not the v1 nested class Config.

4. Serialization — model_dump, JSON, aliases, computed fields

m.model_dump()                  # → dict, Python objects (datetime stays datetime)
m.model_dump(mode="json")       # → dict with JSON-safe values (datetime → str)
m.model_dump_json()             # → JSON str, serialized in Rust (fast)

Key options (apply to all three): include / exclude (sets or nested dicts), by_alias=True (use serialization_alias), exclude_unset (only fields explicitly set — great for PATCH semantics), exclude_defaults, exclude_none, round_trip=True, warnings="error", context=....

5. TypeAdapter — validation/serialization without a BaseModel

TypeAdapter brings Pydantic’s machinery to any type — list[User], dict[str,int], TypedDict, dataclasses, unions — without wrapping it in a model. Build the adapter once (it compiles a core schema) and reuse it.

from pydantic import TypeAdapter
ta = TypeAdapter(list[User])
users = ta.validate_python([{"id": 1}, {"id": 2}])   # list[User]
users = ta.validate_json(raw_bytes)                  # parse + validate in Rust
ta.dump_json(users)                                  # serialize
ta.json_schema()                                     # JSON Schema for the type

Use it for bulk validation of homogeneous collections (build the adapter at module scope, not per call) and for validating request/response bodies that aren’t models.

6. Discriminated (tagged) unions

Add a discriminator so the core validator picks one union member by a tag field instead of trying each — faster, and produces one clean error instead of N.

from typing import Literal, Union, Annotated
from pydantic import BaseModel, Field

class Cat(BaseModel):
    kind: Literal["cat"]; meows: int
class Dog(BaseModel):
    kind: Literal["dog"]; barks: int

class Owner(BaseModel):
    pet: Annotated[Union[Cat, Dog], Field(discriminator="kind")]

For tags that aren’t a plain field, use a callable discriminator via Discriminator(func) — and handle both dict and model inputs inside it, since the callable also runs during serialization. Discriminated unions also emit cleaner OpenAPI/JSON-Schema. Non-discriminated unions use smart mode (best-match) by default; left-to-right is available but usually worse.

7. pydantic-settings — typed configuration

BaseSettings populates fields from (priority high→low): init kwargs → env vars → .env file → secrets dir → field defaults.

from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict

class Settings(BaseSettings):
    model_config = SettingsConfigDict(
        env_prefix="APP_",
        env_file=".env",
        env_nested_delimiter="__",   # APP_DB__HOST → db.host
        secrets_dir="/run/secrets",
        extra="ignore",
    )
    debug: bool = False
    db: "DbConfig"

Tools / Frameworks

Methodology — choosing the right tool

  1. Validating a whole object with named fields?BaseModel.
  2. Validating a bare collection / TypedDict / union, no model needed?TypeAdapter.
  3. Reshaping raw input before typing?@field_validator(mode="before") or a model mode="before" validator.
  4. Cross-field business rule on typed data?@model_validator(mode="after").
  5. Same validation reused across models/types?Annotated[T, AfterValidator(...)].
  6. A union you can tag? → discriminated union (Field(discriminator=...)).
  7. App configuration?BaseSettings from pydantic-settings.
  8. Need exact types, no coercion (e.g. money, ids)?strict=True (per-field or model).

Practical Patterns

Anti-Patterns (and the fix)

Troubleshooting

References