Pydantic v2
Runtime validation for ML configs, API request/response, and structured LLM output.
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 (str → int,
str → float, str → bool). Strict mode (per-field or
model-wide) disables coercion when you want exact matching.
Field — constraints and metadata
The most-used Field constraints:
| Constraint | What it does |
|---|---|
default=X, default_factory=callable | Default value or factory. |
gt, ge, lt, le | Numeric bounds. |
min_length, max_length | String / 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=True | Field 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:
- Schema generation.
ExtractionResult.model_json_schema()produces the JSON schema; OpenAI’s structured output mode uses it to constrain the model’s generation. - Validation.
model_validate_jsonparses the LLM’s response and raisesValidationErrorif it doesn’t match — your downstream code never sees malformed data. - Type safety. Downstream code sees
ExtractionResultandlist[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:
| v1 | v2 |
|---|---|
validator(...) | field_validator(...) |
root_validator(...) | model_validator(...) |
Config inner class | model_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
validatorandfield_validatorraises confusing errors. Pin to v2 and update the decorators. - Coercion you didn’t want.
str → int,int → bool, etc. Usemodel_config = ConfigDict(strict=True)orField(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()vsdict(model). Usemodel_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) needmodel.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
- Pydantic v2 documentation — docs.pydantic.dev — canonical, thorough, well-organised.
- Pydantic v2 migration guide — docs.pydantic.dev — what changed from v1 and how to update.
- pydantic-settings — docs.pydantic.dev — env-driven configs.
- FastAPI documentation — fastapi.tiangolo.com — best in-the-wild example of Pydantic usage at scale.
- instructor — github.com/jxnl/instructor — LLM-output → Pydantic, batteries-included.