Pydantic v2 in depth¶
At a glance
Priority: P0 · Complexity: ⅖ · Est. time: 3 h · Phase: 1 · Prereqs: Advanced typing, Data model You're done when: you can design Pydantic models for an LLM structured-output pipeline (discriminated unions, validators, strict boundaries, JSON Schema tuned for models, partial/streaming validation, retry-on-error) and explain the performance model of pydantic-core.
Why it matters¶
Pydantic is the schema backbone of Python AI engineering (Pydantic 2.13 as of Sept 2026). FastAPI request/response models, Pydantic AI outputs and tools, the OpenAI/Anthropic SDKs' typed responses, LangChain structured output, Instructor, DSPy typed signatures and pydantic-settings configs all use it. An LLM returns untrusted JSON. Pydantic is where "probably-right text" becomes "typed, validated domain objects", and its JSON Schema is what the model sees as the contract. Staff-level questions: strict vs lax at which boundary, schema design that improves model accuracy, validation cost at 10k RPS, and v1 → v2 migration strategy.
Core concepts¶
Architecture: pydantic-core¶
flowchart LR
A[Python class + annotations] -->|build once at class creation| B[core schema]
B --> C[Rust SchemaValidator]
B --> D[Rust SchemaSerializer]
B --> E[JSON Schema generator]
F[JSON bytes / dict] --> C --> G[model instance]
G --> D --> H[dict / JSON bytes]
- Schema build happens once per class or
TypeAdapter. It is expensive (milliseconds for big models), so never createTypeAdapters per request. - Validation runs in Rust. v2 is typically 5–50x faster than v1.
model_validate_json(bytes)parses and validates in one pass in Rust (jiter). It beatsjson.loads+model_validate(about 30% faster in a quick 50-item benchmark on 3.14, and more for big payloads) and avoids building intermediate Python dicts.
The API surface you use daily¶
| Task | v2 API |
|---|---|
| validate dict / JSON | Model.model_validate(d), Model.model_validate_json(s) |
| dump | m.model_dump(mode="json", exclude_none=True, by_alias=True), m.model_dump_json() |
| copy | m.model_copy(update={...}, deep=True) (does not validate update) |
| schema | Model.model_json_schema(mode="validation" \| "serialization") |
| non-model types | TypeAdapter(list[Item]).validate_json(...) |
| skip validation | Model.model_construct(**trusted) (no validation at all) |
| config | model_config = ConfigDict(extra="forbid", frozen=True, strict=True, ...) |
Lax vs strict¶
Lax (the default) coerces: "1" → 1, True → 1, 2.0 → 2, but 1.5 → error (int_from_float). Strict rejects type mismatches.
from pydantic import BaseModel, ConfigDict, Field
from typing import Annotated
class Amount(BaseModel):
model_config = ConfigDict(strict=True)
cents: int
currency: Annotated[str, Field(pattern=r"^[A-Z]{3}$")]
Where to use which: strict for internal service-to-service contracts and tool arguments you control, where silent coercion hides bugs. Lax for human- or LLM-produced input, where "42" for an int is benign, but add constraints (ge, pattern, Literal) so coercion can't smuggle nonsense. Per-call: Model.model_validate(d, strict=True).
Validators¶
from pydantic import BaseModel, field_validator, model_validator, AfterValidator
from typing import Annotated, Self
def upper(v: str) -> str: return v.upper()
Port = Annotated[str, AfterValidator(upper)] # reusable, composable
class Quote(BaseModel):
origin: Port
destination: Port
lines: list[float]
total: float
@field_validator("lines", mode="after")
@classmethod
def non_negative(cls, v: list[float]) -> list[float]:
if any(x < 0 for x in v):
raise ValueError("negative line amount")
return v
@model_validator(mode="after")
def totals_match(self) -> Self:
if abs(sum(self.lines) - self.total) > 0.01:
raise ValueError("lines do not sum to total")
return self
Modes: before (raw input, normalisation), after (typed value, invariants, preferred), wrap (around inner validation, fallbacks and timing), plain (replace validation). Annotated validators are reusable across models and keep models thin.
Senior nuance: raise ValueError/AssertionError (become ValidationError) or PydanticCustomError for typed error codes. Never raise ValidationError directly. Validator error messages are fed back to the LLM in retry loops, so write them for a model to act on ("lines do not sum to total: 12.5 != 13.0").
Discriminated (tagged) unions¶
from typing import Annotated, Literal
from pydantic import BaseModel, Field
class SearchAction(BaseModel):
kind: Literal["search"]
query: str
class BookAction(BaseModel):
kind: Literal["book"]
shipment_id: str
carrier: Literal["MAEU", "MSCU", "CMDU"]
class Final(BaseModel):
kind: Literal["final"]
answer: str
type Action = Annotated[SearchAction | BookAction | Final, Field(discriminator="kind")]
class AgentStep(BaseModel):
thought: str = Field(description="One short sentence of reasoning")
action: Action
Why: O(1) dispatch instead of trying each member (smart mode), one precise error instead of N errors, and a JSON Schema with oneOf + discriminator that models follow much better. For LLM structured outputs this is the pattern for agent actions.
JSON Schema for LLMs¶
model_json_schema()output is what providers' structured-output modes consume. Docstrings becomedescription, andField(description=...)becomes per-field guidance. These are prompts.- Provider strict modes (OpenAI
strict: true, Anthropic structured outputs, Geminiresponse_schema) support only a JSON Schema subset. Usual constraints: all fields required,additionalProperties: false, limitedpattern/format, restricted recursion. SDK helpers transform Pydantic schemas for you. Know what gets dropped (e.g.ge/lemay be stripped), and re-validate the response with Pydantic anyway. - Customise with
json_schema_extra,GenerateJsonSchemasubclasses, orWithJsonSchema. - Prefer
Literal/Enum over free strings, flat over deeply nested, and short field names with good descriptions.
Structured output with validation retry (provider-agnostic)¶
from pydantic import BaseModel, ValidationError
async def extract[T: BaseModel](llm, schema: type[T], prompt: str, max_retries: int = 2) -> T:
messages = [{"role": "user", "content": prompt}]
for attempt in range(max_retries + 1):
raw = await llm.complete(messages, response_schema=schema.model_json_schema())
try:
return schema.model_validate_json(raw)
except ValidationError as e:
if attempt == max_retries:
raise
messages += [
{"role": "assistant", "content": raw},
{"role": "user", "content": f"Fix these validation errors and return only JSON:\n{e.errors(include_url=False)}"},
]
raise AssertionError("unreachable")
That is essentially what Pydantic AI's output validation / ModelRetry and Instructor do. Cap retries, because each one is a full LLM call (cost and latency).
Streaming: partial validation¶
LLM streams arrive as incomplete JSON. TypeAdapter.validate_json(..., experimental_allow_partial=True) (2.10+, still "experimental" as of 2.13) validates the complete prefix and drops the incomplete trailing item:
from typing import TypedDict, NotRequired
from pydantic import TypeAdapter
class Item(TypedDict):
sku: str
qty: NotRequired[int]
ta = TypeAdapter(list[Item])
print(ta.validate_json('[{"sku":"a","qty":2},{"sku":"bo', experimental_allow_partial=True))
# [{'sku': 'a', 'qty': 2}]
Nuance (verified on 2.13): a BaseModel item with a missing required field in the trailing position raises rather than being dropped. Use TypedDicts with NotRequired for the streamed shape, then validate the final complete object against the strict model.
Serialization¶
@field_serializer,@model_serializer,PlainSerializerinAnnotated.mode="json"converts datetimes, UUIDs and Decimals to JSON-safe types.@computed_fieldincludes properties in dumps and serialization schema.exclude,SecretStr(masked in repr and dumps), andField(exclude=True)for internal fields. Critical for not leaking API keys into traces and logs.
pydantic-settings¶
from pydantic import SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="GW_", env_file=".env", env_nested_delimiter="__")
llm_base_url: str
llm_api_key: SecretStr
max_concurrency: int = 32
request_timeout_s: float = 30.0
Fail fast at boot on misconfig, with secrets masked. Also supports secrets directories and custom sources (Azure Key Vault, etc.).
Performance numbers of thumb¶
- Reuse
TypeAdapters at module level. Schema build per request can cost milliseconds. model_validate_json>json.loads+model_validate.list[Model]validation of 10k small objects is on the order of milliseconds. It is rarely the bottleneck next to an LLM call (hundreds of ms to seconds), but it matters in data pipelines. Use Polars/Arrow for bulk tabular data (data tooling).- Avoid
wrapvalidators and Python-levelbeforevalidators on hot fields. They drop back into Python. defer_build=Truein config speeds import time for apps with hundreds of models (serverless cold starts).
v1 → v2 migration map¶
.dict() → model_dump, .json() → model_dump_json, parse_obj → model_validate, @validator → @field_validator, @root_validator → @model_validator, Config class → model_config = ConfigDict(...), orm_mode → from_attributes, __root__ → RootModel. The pydantic.v1 shim namespace allows incremental migration. Per the Pydantic 2.12 release notes the shim is not supported on Python 3.14+ (simple models may still import and run, but don't bet production on it), so finish the migration before upgrading Python.
Resources¶
| Resource | Type | Why this one | Level | Cost |
|---|---|---|---|---|
| Pydantic docs | docs | Official. Concepts section is excellent | intermediate | free |
| Validators | docs | Modes, Annotated validators, ordering | intermediate | free |
| Unions (smart vs discriminated) | docs | The union algorithm, essential for agent actions | advanced | free |
| Performance tips |
docs | Short, high-signal list from the maintainers | advanced | free |
| JSON Schema | docs | Customising the schema the LLM sees | advanced | free |
| Experimental features (partial validation) | docs | Streaming JSON validation | advanced | free |
| Pydantic AI: output | docs | Structured output and retry patterns in an agent framework | intermediate | free |
| Pydantic: Steering LLMs with structured outputs |
article | Why schemas beat prompts for reliable extraction | intermediate | free |
| Hynek: Serialization (domain vs wire models) |
article | Argues for separating domain models from wire models. A needed counterweight | advanced | free |
| Migration guide | docs | v1 → v2 mapping | intermediate | free |
Hands-on lab¶
Goal (60–90 min): a validated invoice-extraction pipeline (feeds the capstone's document-processing path).
- Model
InvoicewithAnnotatedreusable types (Currency,NonNegMoney), a discriminated union ofLineItemkinds (freight,surcharge,tax), amodel_validatorenforcing totals, andConfigDict(extra="forbid"). - Print
Invoice.model_json_schema(). Remove anything your target provider's strict mode rejects (check its docs), and adddescriptions. - Implement the
extract()retry loop with aFakeLLMthat returns invalid JSON first (total mismatch), then valid JSON. Expected output: attempt 1 fails withValue error, lines do not sum to total, attempt 2 returns anInvoice, 2 LLM calls logged. - Swap
FakeLLMfor a real model (Ollama or your provider) on 5 sample invoice texts. Record success rate at attempt 1 vs after retry. - Benchmark:
timeitofmodel_validate_json(raw)vsmodel_validate(json.loads(raw))on a 50-line invoice, 5,000 iterations. Expected:model_validate_jsonwins (about 25–40% on 3.14 in our run). - Streaming: feed the JSON in 20-char chunks to a partial
TypeAdapter(list[LineItemTD])and print the growing list.
Questions¶
L1 — Recall¶
Q1. What does this print?
class M(BaseModel):
x: int
tags: list[str] = []
print(M.model_validate({"x": "1"}), M(x=1).tags is M(x=2).tags)
Answer
x=1 tags=[] False. Lax mode coerces "1" to 1. Unlike a plain class or dataclass, Pydantic copies mutable defaults per instance, so the lists are distinct.
Q2. M(x=1.5) where x: int — result? And M(x=2.0)? And M(x=True)?
Answer
1.5 raises ValidationError (int_from_float, fractional part). 2.0 gives x=2 (exact float allowed in lax). True gives x=1 (bool → int allowed in lax). In strict mode all three fail.
Q3. Name the four validator modes and when you'd use each.
Answer
before: raw input normalisation (strip, parse a legacy format). after: invariants on typed values (the default choice). wrap: run code around the inner validator (fallback defaults, catching errors, timing). plain: fully replace Pydantic's validation for that field.
Q4. What do model_copy(update=...) and model_construct() have in common that bites people?
Answer
Neither validates. model_copy(update={"x": "oops"}) happily sets x to a string on an int field. model_construct builds from trusted data with no checks. Use Model.model_validate({**m.model_dump(), **changes}) when the update is untrusted.
L2 — Apply¶
Q5. Fix: an API creates TypeAdapter(list[Event]) inside the request handler and p99 went up 8 ms.
Answer
Building a TypeAdapter builds the core schema and Rust validator every call. Hoist it to module level (EVENTS = TypeAdapter(list[Event])) or cache it with functools.cache keyed by type. Then use EVENTS.validate_json(body) directly on raw bytes.
Q6. An LLM returns {"action": {"kind": "book", "shipment_id": "S1", "carrier": "MAERSK"}} for the AgentStep model above. How many errors and what message does the model get back?
Answer
With a discriminated union: one error at action.book.carrier, literal_error, "Input should be 'MAEU', 'MSCU' or 'CMDU'". Without the discriminator, smart-mode union would report errors from every member (search: missing query; book: literal; final: missing answer), which is noisy and confuses the retry prompt. Feed e.errors(include_url=False) back to the model.
Q7. Your settings class logs itself at startup and API keys leak into logs. Fix it.
Answer
Type secrets as SecretStr. The repr and model_dump() show '**********', and you call .get_secret_value() only at the point of use. Also add Field(exclude=True) or repr=False for other sensitive fields, and never log model_dump(mode="python") of request objects containing PII without a redaction serializer.
Q8. Write a reusable Annotated type Iso3Currency that uppercases, validates 3 letters, and shows pattern in JSON Schema.
Answer
from typing import Annotated
from pydantic import BeforeValidator, Field
Iso3Currency = Annotated[str, BeforeValidator(lambda v: v.upper() if isinstance(v, str) else v),
Field(pattern=r"^[A-Z]{3}$", description="ISO-4217 code")]
BeforeValidator so uppercasing happens before the pattern check. Reuse it across models.
L3 — Design & trade-offs¶
Q9. One Pydantic model for API, DB and LLM schema, or separate models per layer? Decide for a quoting service.
Answer
A single model couples three rates of change: API contract (versioned, public), persistence (migrations), and LLM schema (tuned descriptions, flattened, Literal-heavy). Changing a description to improve model accuracy shouldn't change your public API. Recommendation: domain model (dataclass or frozen Pydantic), API DTOs, and LLM output models, with explicit mapping functions. Accept the boilerplate for decoupling. For small internal tools, one model is fine. Revisit when a second consumer appears. (Hynek argues for this separation.)
Q10. Strict vs lax validation for tool-call arguments produced by an LLM.
Answer
LLMs often emit "3" for ints or "true" for bools, and lax mode absorbs benign drift, reducing retries (cost and latency). But lax can hide real errors (e.g. "2024-13-01" fails anyway, while 1.0 for an ID int passes). Use lax parsing plus tight semantic constraints (Literal, ge/le, patterns) plus provider strict structured outputs where available (grammar-constrained decoding makes type errors rare). Use strict for tools with side effects (payments, bookings), where you'd rather fail and ask again than coerce.
Q11. At 20k RPS, profiling shows 18% CPU in Pydantic validation of a large nested response model. Options?
Answer
1) Don't validate what you produced yourself: for trusted internal outputs, construct directly or use model_construct, or return Response with pre-serialised JSON. 2) Use model_validate_json on bytes instead of dict round-trips. 3) Remove Python before/wrap validators on hot paths (move them to Annotated Rust-backed constraints). 4) Validate at the edge once, not at every internal hop. 5) If the payload is tabular/bulk, use Arrow/Polars or msgspec for that path. 6) FastAPI: declare response_model wisely, since it re-validates or serialises output. Measure with py-spy before and after (performance).
L4 — Staff-level ambiguity¶
Q12. 25 services are still on Pydantic v1 (some via pydantic.v1), and the platform wants Python 3.14. Plan the migration.
Answer
Constraint: the pydantic.v1 shim is officially unsupported on 3.14+, so migration effectively blocks the Python upgrade. Plan: inventory (grep for pydantic.v1, .dict(, @validator, orm_mode), rank by traffic and risk, and apply the bump-pydantic codemod plus manual review of validators (semantics changed: always, pre, each_item). Add contract snapshot tests (JSON Schema and dump outputs) before migrating, so behaviour diffs are visible, especially coercion changes (v2 no longer coerces int → str). Shared-libs-first ordering. Timebox, and migration office hours. Track: services on v2, 3.14 adoption.
Q13. Product wants 'guaranteed valid JSON' from LLM extraction across 3 providers. What do you commit to and how do you architect it?
Answer
You can guarantee schema-valid output (provider constrained decoding plus Pydantic re-validation plus bounded retries plus a fallback path), not correct output. Architecture: a single Pydantic schema per task, a provider adapter that transforms it to each provider's supported subset (tested with snapshot tests), validation plus retry with error feedback (max 2), and on failure a dead-letter queue for human review. Measure first-pass validity, post-retry validity, and field-level accuracy on a labelled eval set (evals). Semantic checks (totals match, dates plausible) live in model_validators. Communicate SLOs as "99.x% schema-valid, Y% field accuracy on eval set".
Real-world use cases¶
- Document processing (bills of lading, invoices): LLM extraction into discriminated-union line items, with validators enforcing totals, and failures routed to human review.
- Agent step parsing:
AgentStepwith a discriminatedactionunion makes the orchestrator'smatchexhaustive and type-safe. - Config at scale: pydantic-settings with
env_nested_delimiteracross 40 microservices. Misconfig fails at boot, not at the first request. - Event contracts: frozen,
extra="forbid"models for Kafka events, with JSON Schema published to a schema registry for Java consumers.
Pitfalls & anti-patterns¶
- Creating
TypeAdapters or models dynamically per request. - Trusting LLM JSON without re-validation even with "strict" provider modes.
- Validation logic scattered in endpoints instead of model or Annotated validators.
model_copy(update=...)with untrusted data.- Giant god-models reused across API, DB and LLM layers.
extra="allow"on inbound contracts (silently accepts typos).- Error messages useless to a model in retry loops.
- Not pinning Pydantic minor versions in libraries with complex validators (behaviour tweaks land in minors).
Checklist¶
- I can explain lax vs strict, validator modes, and discriminated unions without notes
- I built the extraction pipeline with a validation retry loop and measured first-pass validity
- I benchmarked
model_validate_jsonvsjson.loads+model_validate - I answered all L3 questions out loud in < 3 min each