Skip to content

TL;DR

Pydantic is what you reach for when “the data crossed a boundary.” Crossed a network → Pydantic. Crossed a YAML / JSON file → Pydantic. Came out of an LLM as JSON → Pydantic. Anywhere data enters your process from outside, you want runtime validation, and Pydantic is the ecosystem standard.

It works by defining a BaseModel subclass with type-annotated fields. At construction time, Pydantic validates and coerces every field according to those annotations: a string "3" becomes the int 3 if the field is int; a missing required field raises a clear error; an out-of-range value raises a clear error. The annotations are the schema; the validation is automatic.

from pydantic import BaseModel, Field

class TrainConfig(BaseModel):
    learning_rate: float = Field(gt=0, lt=1, default=3e-4)
    batch_size: int = Field(ge=1, le=4096, default=32)
    epochs: int = Field(ge=1, default=10)
    seed: int = 42

Pydantic v2 (released 2023) rewrote the validation core in Rust and is ~5–50× faster than v1. If you’re starting today, use v2; v1 is in maintenance.

The line: dataclasses for internal data, Pydantic for boundary data. Pydantic does runtime work; dataclasses don’t. Don’t pay the overhead when you don’t need it.

The picture in your head

Pydantic is a parser-validator that uses your type annotations as the spec. Construction is validation:

TrainConfig(learning_rate=3e-4, batch_size=32)        # valid: returns instance
TrainConfig(learning_rate=2.0, batch_size=32)         # ValidationError: lr must be < 1
TrainConfig(learning_rate="3e-4", batch_size="32")    # valid: coerced from strings
TrainConfig(learning_rate=3e-4)                        # valid: batch_size defaults to 32

Compared to a dataclass: a dataclass takes whatever you give it and trusts the annotations. Pydantic enforces them.

The basics

from pydantic import BaseModel

class User(BaseModel):
    id: int
    name: str
    email: str | None = None

u = User(id=1, name="alice")
# User(id=1, name='alice', email=None)

u = User(id="1", name="alice")        # "1" coerced to 1
u = User(id="oops", name="alice")     # ValidationError

Every field is required unless it has a default. Type coercion happens automatically for compatible types (strint, strfloat, strbool). Strict mode (per-field or model-wide) disables coercion when you want exact matching.

Field — constraints and metadata

The most-used Field constraints:

ConstraintWhat it does
default=X, default_factory=callableDefault value or factory.
gt, ge, lt, leNumeric bounds.
min_length, max_lengthString / list / dict bounds.
pattern="^[a-z]+$"Regex on strings.
description="..."Human-readable; shows up in JSON schema.
alias="json_name"Map between Python name and external name.
frozen=TrueField can’t be reassigned after construction.
from pydantic import BaseModel, Field

class Hyperparams(BaseModel):
    learning_rate: float = Field(gt=0, lt=1, default=3e-4,
                                  description="Adam learning rate")
    optimizer: str = Field(pattern="^(adam|adamw|sgd)$", default="adamw")
    batch_size: int = Field(ge=1, le=4096, default=32)

A ValidationError from this class tells you exactly which field failed and why — no more “the YAML loaded fine but training crashed at step 3 because batch_size was ‘thirty-two’.”

Validators — custom rules

For rules that don’t fit a single constraint, use validators. @field_validator runs against one field; @model_validator runs against the whole model after all fields are populated.

from pydantic import BaseModel, field_validator, model_validator

class TrainConfig(BaseModel):
    train_split: float
    val_split: float
    test_split: float

    @field_validator("train_split", "val_split", "test_split")
    @classmethod
    def in_unit_interval(cls, v: float) -> float:
        if not (0.0 <= v <= 1.0):
            raise ValueError("must be in [0, 1]")
        return v

    @model_validator(mode="after")
    def splits_sum_to_one(self) -> "TrainConfig":
        total = self.train_split + self.val_split + self.test_split
        if abs(total - 1.0) > 1e-6:
            raise ValueError(f"splits must sum to 1, got {total}")
        return self

mode="after" means “run after Pydantic populated the fields”; mode="before" means “run on the raw input dict before any field validation.”

JSON in / JSON out

Pydantic’s bread and butter. Two methods:

# JSON / dict in
cfg = TrainConfig.model_validate({"learning_rate": 3e-4, "batch_size": 32})
cfg = TrainConfig.model_validate_json('{"learning_rate": 3e-4, "batch_size": 32}')

# JSON / dict out
cfg.model_dump()         # dict
cfg.model_dump_json()    # JSON string
cfg.model_json_schema()  # JSON schema (for OpenAPI, LLM tool calling, etc.)

model_validate_json is faster than model_validate(json.loads(...)) because Pydantic v2 has its own SIMD-accelerated JSON parser.

Settings management — pydantic-settings

For environment-variable / .env driven configs, use the pydantic-settings package (split off from core in v2):

from pydantic_settings import BaseSettings, SettingsConfigDict

class AppSettings(BaseSettings):
    model_config = SettingsConfigDict(env_file=".env", env_prefix="ML_")

    openai_api_key: str
    model_name: str = "gpt-4o-mini"
    max_tokens: int = 1024

settings = AppSettings()
# Reads from env: ML_OPENAI_API_KEY, ML_MODEL_NAME, ML_MAX_TOKENS

This is how every modern ML service should load configuration. No os.environ.get(...) scattered through the codebase. One typed settings object loaded once at startup; ValidationError if anything’s missing or wrong.

A worked ML example — structured LLM output

The single most common Pydantic use case in 2026: parsing LLM JSON output. The model returns text shaped like JSON; Pydantic validates the shape and coerces it into typed Python.

from pydantic import BaseModel, Field
from openai import OpenAI

client = OpenAI()

class ExtractedEntity(BaseModel):
    name: str
    type: str = Field(pattern="^(person|organization|location|other)$")
    confidence: float = Field(ge=0.0, le=1.0)

class ExtractionResult(BaseModel):
    entities: list[ExtractedEntity]
    summary: str = Field(max_length=500)

def extract(text: str) -> ExtractionResult:
    resp = client.chat.completions.create(
        model="gpt-4o-mini",
        response_format={"type": "json_schema",
                          "json_schema": {"name": "extract",
                                          "schema": ExtractionResult.model_json_schema()}},
        messages=[
            {"role": "system", "content": "Extract named entities from the text."},
            {"role": "user", "content": text},
        ],
    )
    return ExtractionResult.model_validate_json(resp.choices[0].message.content)

Three things happening:

  1. Schema generation. ExtractionResult.model_json_schema() produces the JSON schema; OpenAI’s structured output mode uses it to constrain the model’s generation.
  2. Validation. model_validate_json parses the LLM’s response and raises ValidationError if it doesn’t match — your downstream code never sees malformed data.
  3. Type safety. Downstream code sees ExtractionResult and list[ExtractedEntity] — typed, IDE-completable, no dict-key guessing.

This pattern (Pydantic as the contract between your code and the LLM) is the modern best practice. Libraries like instructor and outlines formalise it further.

v1 vs v2 — what changed

If you’re upgrading existing code:

v1v2
validator(...)field_validator(...)
root_validator(...)model_validator(...)
Config inner classmodel_config = ConfigDict(...)
parse_obj(...)model_validate(...)
parse_raw(...)model_validate_json(...)
dict(...)model_dump(...)
json(...)model_dump_json(...)
BaseSettings (built-in)pydantic-settings (separate package)

The migration guide in the official docs has a more complete table. Most v1 projects can migrate field-by-field; the breaking changes are at the API level, not the model-definition level.

Common gotchas

  • Pydantic v1 syntax in v2 code. Mixing validator and field_validator raises confusing errors. Pin to v2 and update the decorators.
  • Coercion you didn’t want. str → int, int → bool, etc. Use model_config = ConfigDict(strict=True) or Field(strict=True) to disable coercion per field.
  • Deeply nested validation cost. Pydantic re-validates every nested model on every construction. Don’t put Pydantic models on the hot path of a tight inner loop. Use dataclasses there.
  • model_dump() vs dict(model). Use model_dump(). dict(model) works in v2 but doesn’t recurse and may not handle every type the same way.
  • Forward references (name: "OtherModel" as a string) need model.model_rebuild() after both classes are defined, in some circular-reference cases.

When Pydantic is the wrong tool

  • Internal record types. Use a dataclass. No runtime overhead.
  • Performance-critical inner loops. Validation has cost. Build the Pydantic model once at the boundary, then pass plain values.
  • PyTorch tensors / NumPy arrays as fields. Pydantic supports them via arbitrary_types_allowed=True, but it’s awkward; the validation for ndarrays is mostly “is it an ndarray?” which doesn’t earn its keep. Use a dataclass or skip the wrapper.

Where Pydantic shows up in real ML codebases

  • FastAPI — every request/response model is a Pydantic class.
  • OpenAI / Anthropic Python SDKs — request and response objects are Pydantic models.
  • LangChain / LlamaIndex — tool / function definitions, chain configs, structured outputs all use Pydantic.
  • instructor — wraps LLM calls to return Pydantic instances directly.
  • MLflow — REST clients use Pydantic for request/response shapes.
  • W&B Launch — job configs are Pydantic-validated.
  • Anything that loads a YAML/JSON config from disk and uses it.

The defensive habit: at every boundary, write a Pydantic model. In the middle of your code, use dataclasses or plain types. The Pydantic model is the contract; everything inside the contract is plain Python.

Resources