| name | pydantic-v2 |
| description | Pydantic v2 patterns for production Python: validators (`@field_validator`, `@model_validator`), computed fields, strict types, discriminated unions, settings management, `model_validate` / `model_dump`, `condecimal` and `Annotated[Decimal, ...]` for money, performance tips, and a v1 to v2 migration checklist. Also covers FastAPI integration (response_model serialization, request validation, error envelope customization). TRIGGER WHEN: writing or refactoring Pydantic models in Python 3.10+; migrating a codebase from Pydantic v1 to v2; choosing between `Annotated[Decimal, ...]` vs `condecimal`; hitting v2 performance or serialization surprises; designing FastAPI request/response schemas or error envelopes with Pydantic. DO NOT TRIGGER WHEN: the task is Python testing (use python-tdd), generic typing unrelated to Pydantic (use mypy / typing docs), or non-Python schema work (use typescript-development for Zod / io-ts).
|
Pydantic v2
Pydantic v2 (released 2023-06, current stable 2.13 as of 2026-04-19, paired with Python 3.10-3.14 including 3.14 free-threaded builds) is a near-complete rewrite on pydantic-core (Rust). API is similar but not identical to v1 -- several v1 patterns silently break or behave differently. This skill documents the v2 idioms, the v1 migration gotchas, and the FastAPI integration surface.
Notable recent releases:
- 2.11 (2025): 2x schema build-time improvements, 2-5x memory reduction for nested models, PEP 695/696 generic syntax, experimental free-threaded Python 3.13,
validate_by_alias / validate_by_name / serialize_by_alias config (populate_by_name pending v3 deprecation), Path and deque no longer accept constraints (2.11 release).
- 2.12: Python 3.14 support (PEP 649/749 annotations), experimental
MISSING sentinel, exclude_if on fields, ensure_ascii on JSON output, serialize_as_any unified behavior, @model_validator(mode="after") classmethod deprecated -- write as instance method (2.12 release).
- 2.13 (April 2026): Polymorphic serialization (
model_dump(polymorphic_serialization=True)), exclude_if extended to computed fields, ascii_only in StringConstraints, model_fields_set tracks post-instantiation extras (2.13 release).
When to load which section
- Writing a new model from scratch -> "Core model patterns" + "Validators"
- Migrating from v1 -> "v1 -> v2 migration checklist"
- Working with money / decimals -> "Monetary precision (CWE-681 defense)"
- FastAPI request/response models -> "FastAPI integration"
- Performance-sensitive hot path -> "Performance notes"
- Secret handling / redaction -> "Security and secrets"
- Validation observability or LLM agents -> "PydanticAI and Logfire"
Core model patterns
from datetime import datetime
from decimal import Decimal
from typing import Annotated, Literal
from pydantic import (
BaseModel,
ConfigDict,
Field,
StringConstraints,
computed_field,
field_validator,
model_validator,
)
NonEmptyStr = Annotated[str, StringConstraints(min_length=1, strip_whitespace=True)]
USDAmount = Annotated[Decimal, Field(max_digits=14, decimal_places=2, ge=0)]
class LineItem(BaseModel):
model_config = ConfigDict(
strict=True,
frozen=True,
populate_by_name=True,
str_strip_whitespace=True,
extra="forbid",
)
sku: NonEmptyStr
quantity: int = Field(ge=1)
unit_price: USDAmount
currency: Literal["USD", "EUR", "GBP"] = "USD"
@computed_field
@property
def total(self) -> USDAmount:
return self.unit_price * self.quantity
@field_validator("sku")
@classmethod
def sku_format(cls, v: str) -> str:
if not v.isalnum():
raise ValueError("sku must be alphanumeric")
return v.upper()
@model_validator(mode="after")
def cross_field_check(self) -> "LineItem":
if self.currency == "USD" and self.unit_price > Decimal("10000"):
raise ValueError("USD line item exceeds single-item limit")
return self
Key points
model_config = ConfigDict(...) replaces the v1 inner class Config: pattern. Importing ConfigDict gives you autocomplete.
Annotated[T, Field(...)] or Annotated[T, StringConstraints(...)] is the idiomatic v2 way to attach constraints -- prefer over constr(), conint(), condecimal() wrappers (still supported, but Annotated composes better with Python's type system).
@computed_field replaces the v1 @property + validator(always=True) dance; shows up in model_dump() automatically.
@field_validator("field_name") replaces v1 @validator("field_name"). Decorator is classmethod; must have @classmethod decorator after @field_validator.
@model_validator(mode="before" | "after") replaces v1 @root_validator(pre=True | False). In mode="after" the method returns self, not a dict. Note (2.12+): @classmethod on mode="after" is deprecated -- write as a plain instance method.
- Prefer
validate_by_alias / validate_by_name / serialize_by_alias (2.11+) over the legacy populate_by_name=True. The latter is pending deprecation in v3.
Path and deque no longer accept Field constraints (2.11+) -- use a custom validator for length / pattern checks.
MISSING sentinel (2.12, experimental) lets you distinguish "not provided" from "explicit default" at validator time.
Validators
Mode decision rules
| Mode | When to use | Performance | Returns |
|---|
after (default) | 90% of cases -- logic on already-coerced value | Fast | Same type as field |
before | Reshape raw input (string split, legacy key mapping) | Python callback overhead | Any (will be type-coerced afterward) |
wrap | Catch ValidationError, add logging, force PydanticUseDefault | Slowest -- materializes data in Python | Same type as field |
plain | Fully custom, no coercion, no downstream validators | N/A (terminates validation) | Trusted as-is -- dangerous for type safety |
Performance rule: express constraints in Field(...) or Annotated metadata whenever possible -- the Rust engine handles them. Python-level validators (@field_validator, mode="before", mode="wrap") incur per-value Python-call overhead (Performance guide).
Field validators
from pydantic import BaseModel, ValidationInfo, field_validator
class User(BaseModel):
email: str
password: str
@field_validator("email")
@classmethod
def email_lowercase(cls, v: str) -> str:
return v.lower()
@field_validator("password")
@classmethod
def password_strength(cls, v: str, info: ValidationInfo) -> str:
if len(v) < 12:
raise ValueError("password must be at least 12 chars")
return v
Annotated validators (reusable pattern)
Prefer Annotated metadata over decorator validators when the same logic applies across multiple models:
from typing import Annotated
from pydantic import AfterValidator, BeforeValidator, WrapValidator, BaseModel
def lower(v: str) -> str:
return v.lower()
def ensure_https(v: str) -> str:
if not v.startswith("https://"):
raise ValueError("URL must be https")
return v
LowerStr = Annotated[str, AfterValidator(lower)]
HttpsUrl = Annotated[str, AfterValidator(ensure_https)]
class User(BaseModel):
email: LowerStr
avatar_url: HttpsUrl
Ordering rule: Before and Wrap validators run right-to-left; After validators run left-to-right; decorator-style @field_validators are appended last.
ValidationInfo and info.context
Stable API across 2.x. Context passes through to custom serializers (2.7+):
user = User.model_validate(payload, context={"tenant_id": request.tenant_id})
Use context for tenant-aware validation, feature flags, or bypass switches -- NOT for data that should be on the model.
Model validators
@model_validator(mode="before")
@classmethod
def normalize_input(cls, data: dict) -> dict:
if isinstance(data, dict) and "userEmail" in data and "user_email" not in data:
data["user_email"] = data.pop("userEmail")
return data
@model_validator(mode="after")
def check_date_order(self) -> "Event":
if self.start_date > self.end_date:
raise ValueError("start_date must be <= end_date")
return self
Error customization
from pydantic_core import PydanticCustomError
@field_validator("sku")
@classmethod
def sku_format(cls, v: str) -> str:
if not v.isalnum():
raise PydanticCustomError(
"invalid_sku",
"{field} must be alphanumeric",
{"field": "sku"},
)
return v
- Raising
ValueError auto-wraps into a Pydantic error -- simplest path.
- Avoid
assert -- skipped under python -O.
loc (error location) is determined by validator position; not user-editable at raise site.
Inheritance
Base-class model validators run on subclass instances. Overriding in a subclass replaces (does not extend) the parent. Prefer composition via Annotated validators over deep inheritance.
Serialization: model_dump, model_dump_json, model_validate
v2 renamed the v1 methods:
| v1 | v2 |
|---|
model.dict() | model.model_dump() |
model.json() | model.model_dump_json() |
Model.parse_obj(...) | Model.model_validate(...) |
Model.parse_raw(...) | Model.model_validate_json(...) |
Model.construct(...) | Model.model_construct(...) |
Model.schema() | Model.model_json_schema() |
Model.__fields__ | Model.model_fields |
All deprecated v1 names still work with deprecation warnings. Production: use the v2 names.
Serialization modes
model.model_dump(mode="python")
model.model_dump(mode="json")
model.model_dump(mode="strings")
model.model_dump(exclude={"password_hash"})
model.model_dump(include={"id", "name"})
model.model_dump(exclude_none=True)
model.model_dump(by_alias=True)
model.model_dump(round_trip=True)
model.model_dump(polymorphic_serialization=True)
Custom serializers
Three flavors, use the one that matches the shape:
from typing import Annotated
from pydantic import (
BaseModel, field_serializer, model_serializer,
PlainSerializer, WrapSerializer,
SerializerFunctionWrapHandler,
)
class Order(BaseModel):
total: Decimal
created_at: datetime
@field_serializer("total", when_used="json")
def serialize_total(self, v: Decimal) -> str:
return f"${v:.2f}"
@model_serializer(mode="wrap")
def add_envelope(self, handler: SerializerFunctionWrapHandler) -> dict:
return {"version": 1, "data": handler(self)}
UsdAmount = Annotated[
Decimal,
PlainSerializer(lambda v: f"${v:.2f}", return_type=str, when_used="json"),
]
when_used values: "always" (default), "json", "json-unless-none", "unless-none".
JSON Schema modes
model_json_schema(mode="validation" | "serialization") returns different shapes:
Model.model_json_schema(mode="validation")
Model.model_json_schema(mode="serialization")
FastAPI uses both: validation mode for request schemas, serialization mode for response schemas. Pydantic v2 targets OpenAPI 3.1 natively.
Aliases: three flavors
| Field arg | Affects |
|---|
alias="x" | Both validation input AND serialization output |
validation_alias="x" | Input only |
serialization_alias="x" | Output only |
Pydantic 2.11 added per-config control:
class ApiResponse(BaseModel):
model_config = ConfigDict(
validate_by_alias=True,
validate_by_name=True,
serialize_by_alias=True,
)
user_id: str = Field(alias="userId")
populate_by_name still works but is pending v3 deprecation -- migrate now.
Fast JSON emission
For hot paths, skip the Python dict materialization:
raw = model.model_dump_json()
raw_bytes = MyModel.__pydantic_serializer__.to_json(model)
Cache __pydantic_serializer__ at module scope for ultra-hot paths (SSE, WebSocket, bulk export).
Round-tripping
model.model_dump(round_trip=True) guarantees Model.model_validate(dumped) yields an equivalent instance for RootModels, discriminated unions, and aliased fields. Mutable cached state (computed_fields with side-effects) is not guaranteed to survive round-trip.
Discriminated unions
The pattern for "this field determines which model to parse":
from typing import Annotated, Literal, Union
from pydantic import BaseModel, Field
class EmailEvent(BaseModel):
type: Literal["email"]
to: str
subject: str
class SmsEvent(BaseModel):
type: Literal["sms"]
to: str
body: str
Event = Annotated[Union[EmailEvent, SmsEvent], Field(discriminator="type")]
class Envelope(BaseModel):
id: str
event: Event
Envelope.model_validate({"id": "1", "event": {"type": "email", "to": "x@y", "subject": "hi"}})
Faster than v1 unions (pydantic-core picks the variant without trying each) and produces cleaner error messages.
Monetary precision (CWE-681 defense)
Never use float for money. Two idiomatic v2 options:
from decimal import Decimal
from typing import Annotated
from pydantic import BaseModel, Field, condecimal
USDAmount = Annotated[Decimal, Field(max_digits=14, decimal_places=2, ge=0)]
USDAmountV1Style = condecimal(max_digits=14, decimal_places=2, ge=0)
class Invoice(BaseModel):
subtotal: USDAmount
tax: USDAmount
total: USDAmount
For currency arithmetic, pair with decimal.getcontext() for rounding mode:
from decimal import ROUND_HALF_EVEN, getcontext
getcontext().rounding = ROUND_HALF_EVEN
See senior-review:defect-taxonomy CWE-681 / CWE-682 for the full monetary-precision rules.
Settings management (pydantic-settings)
v2 extracted settings into a separate package pydantic-settings:
from pydantic import Field, SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict
class AppSettings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
env_prefix="APP_",
case_sensitive=False,
)
database_url: str
stripe_secret_key: SecretStr
debug: bool = False
port: int = Field(default=8000, ge=1, le=65535)
settings = AppSettings()
SecretStr / SecretBytes hide the value in logs / tracebacks -- use for every API key, password, or token
- Reading the value:
settings.stripe_secret_key.get_secret_value()
- Source priority (high -> low): init args > env vars > .env file > defaults
FastAPI integration
Response models
from fastapi import FastAPI
from pydantic import BaseModel
class UserOut(BaseModel):
id: int
email: str
class UserDB(UserOut):
password_hash: str
app = FastAPI()
@app.get("/users/{user_id}", response_model=UserOut)
async def get_user(user_id: int) -> UserDB:
return await fetch_user(user_id)
FastAPI uses the response_model for serialization, which strips password_hash. Prefer this to manual dict construction.
Request validation and custom error envelopes
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
return JSONResponse(
status_code=422,
content={
"error": "validation_failed",
"details": exc.errors(),
"request_id": request.headers.get("x-request-id"),
},
)
v2 error shape differs from v1: ctx, input, and url fields changed. If you serialize errors to clients, pin the shape in an integration test.
Streaming / long-lived response models
model_dump_json() is faster than json.dumps(model.model_dump()) for streaming. For SSE / WebSocket, cache the serializer:
from typing import Any
serializer = MyModel.__pydantic_serializer__
raw = serializer.to_json(instance)
Performance notes
Ordered guide from the official Performance concept:
model_validate_json(raw_bytes) over model_validate(json.loads(raw_bytes)) -- skips Python dict materialization; pydantic-core streams directly from bytes.
- Instantiate
TypeAdapter at module scope, never inside request handlers. Each instantiation rebuilds validators.
- Concrete types beat abstract ones --
list[int] beats Sequence[int]; dict beats Mapping.
- Discriminated unions >> untagged unions -- direct lookup via the tag field, no scoring of all variants.
TypedDict via TypeAdapter is ~2.5x faster than an equivalent nested BaseModel for pure-data shapes.
- Avoid wrap validators in hot paths -- they materialize data in Python.
- Core-side constraints (
Field(ge=...)) >> Python @field_validator -- express ranges, lengths, regex patterns as metadata.
FailFast annotation on sequences (2.8+) stops validation on first failure instead of collecting all errors:
from typing import Annotated
from pydantic import FailFast
ids: Annotated[list[int], FailFast()]
cache_strings="all" (default) -- Pydantic caches repeated strings across validation calls. Memory-safe for typical API payloads; set to "none" only for adversarial input with high string entropy.
defer_build=True in ConfigDict -- delay schema build until first validation. Useful when you define many models at import but use only a subset per request. 2.11 extended to @validate_call; 2.8 added experimental TypeAdapter support. Harmful if you want startup-time errors on malformed schemas.
MyModel.__pydantic_serializer__.to_json(instance) -- bytes fast-path for SSE / WebSocket / bulk export. Cache the serializer at module scope.
model_construct(**data) -- skips all validation and coercion. Safe for already-validated data (DB rows matching schema, internal cloning, test fixtures). Unsafe for any input that has crossed a trust boundary.
Free-threaded Python (3.13+)
Pydantic 2.11 added experimental free-threaded (nogil) build support; 2.12 ships official builds for Python 3.14 free-threaded. Treat as preview, not production, until a stability signal from the Pydantic team.
model_rebuild()
Needed when a forward-referenced model is defined out of order or when forward refs haven't resolved. Harmful if called in a hot path -- rebuilds the Rust-side schema from scratch.
Security and secrets
SecretStr / SecretBytes
from pydantic import BaseModel, SecretStr
class Credentials(BaseModel):
api_key: SecretStr
c = Credentials(api_key="sk-real-secret-123")
print(c)
print(c.model_dump_json())
c.api_key.get_secret_value()
Redacted in repr() and JSON output by default -- stops accidental logging and traceback leaks. To expose to a trusted sink (secret manager persistence), use a custom @field_serializer("api_key", when_used="always") that calls .get_secret_value().
Untrusted input safety
TypeAdapter.validate_python(untrusted) has the same safety model as BaseModel.model_validate -- no more, no less permissive.
model_validate_json(raw_bytes) streams through pydantic-core with built-in depth / size bounds; prefer over json.loads + model_validate.
- Deep recursive models: pydantic-core has Rust-side stack safety for JSON traversal, but Python-level validators at depth can still overflow. Cap recursion via a
mode="before" validator that counts.
- Mutable nested objects in
model_dump() output alias back to the model (shallow copy) -- see gotcha above.
PydanticAI and Logfire
Two first-party tools worth knowing when building LLM-driven features:
- Logfire -- Pydantic's observability tool.
logfire.instrument_pydantic(record="failure") traces every failed validation in production with span metadata; record="all" traces successful validations too (use sparingly). logfire.instrument_pydantic_ai() instruments PydanticAI agents end-to-end (tool calls, LLM requests, token usage).
- PydanticAI -- type-safe LLM agent framework built on Pydantic models. Supports OpenAI, Anthropic, Gemini, DeepSeek, Grok, Cohere, Mistral, Perplexity, Azure, Bedrock, Vertex, Ollama, Groq, OpenRouter, Together, Fireworks. Features: durable-agent execution (resume after failure), built-in evals, MCP support, Logfire tracing. Use when your domain models are already Pydantic and you want agent outputs validated against those same models.
v1 -> v2 migration checklist
Run bump-pydantic for the mechanical transforms:
uvx bump-pydantic src/
Then handle the cases the bot can't:
@validator -> @field_validator + @classmethod decorator above the validator
@root_validator(pre=True) -> @model_validator(mode="before"), method is classmethod, first arg is cls, takes raw dict
@root_validator (no pre) -> @model_validator(mode="after"), method returns self and is a plain instance method (2.12+ deprecated the classmethod form)
Config.allow_population_by_field_name -> ConfigDict(validate_by_name=True) (and validate_by_alias=True, serialize_by_alias=True as needed). The older populate_by_name=True still works but is pending v3 deprecation.
Config.orm_mode -> model_config = ConfigDict(from_attributes=True)
Config.schema_extra -> model_config = ConfigDict(json_schema_extra={...})
Config.extra = "allow"|"forbid"|"ignore" -> ConfigDict(extra=...)
parse_obj -> model_validate; parse_raw -> model_validate_json; dict() -> model_dump(); json() -> model_dump_json()
- Generic
BaseModel[T] -- v2 uses native Python generics, not GenericModel; PEP 695 syntax supported since 2.11
Field(..., env="FOO") in settings -> moved to pydantic-settings package (BaseSettings is no longer in pydantic)
__fields__ -> model_fields (the shape changed: FieldInfo objects, not ModelField)
__root__ model -> use RootModel[T] (from pydantic import RootModel)
Optional[X] = None without default -> still optional but now must have default; v1 allowed no default on Optional, v2 requires it
smart_union -> default behavior in v2; old smart_union=True is a no-op
ValidationError.errors() format changed: msg, type, loc, input, ctx, url -- regenerate any error-envelope tests
parse_obj_as(list[Foo], data) -> TypeAdapter(list[Foo]).validate_python(data) (instantiate adapter at module scope)
What bump-pydantic misses
Requires manual conversion:
- Custom
__get_validators__ / __modify_schema__ -> __get_pydantic_core_schema__ + __get_pydantic_json_schema__
each_item=True validators on sequence fields
- Custom
json_encoders in Config -> @field_serializer or Annotated PlainSerializer
- Complex wrap validators with branching logic
@validator(always=True) on computed fields -> @computed_field
Always diff the changes manually after running bump-pydantic.
Common gotchas
strict=True breaks JSON API boundaries: by default, FastAPI coerces "1" -> 1 for path/query params. If you set strict=True globally, int query params must be sent as JSON numbers. Apply strict selectively (per-field or per-call).
extra="forbid" + evolving clients: rejects unknown fields. Fine for internal APIs, risky for public APIs where clients may send forward-compatible fields. Use "ignore" (default) for public ingress.
extra="allow" + model_dump(): v2 excludes extras from dumps by default (v1 included them). 2.13 tracks post-instantiation extra assignments in model_fields_set; set ConfigDict(extra="allow") and explicitly include extras when needed (issue #5683).
@computed_field with expensive work: runs on every model_dump(). Cache if expensive, or use a regular property (which won't be serialized).
- Frozen models are hashable: useful for use as dict keys / in sets, but
model_copy(update={...}) is now required to "change" one -- can't just assign.
model_validate_json raw bytes vs str: accepts both; bytes path avoids decode cost on hot paths.
- Discriminator must be a
Literal, not str: Field(discriminator="type") requires each union member to declare type: Literal["..."], not type: str.
- v2 does NOT validate on
__setattr__ by default: assigning to a mutable model attribute after construction skips validation. Set ConfigDict(validate_assignment=True) if you need it.
@model_validator(mode="after") classmethod is deprecated (2.12+): write as a plain instance method returning self.
populate_by_name pending v3 deprecation: use validate_by_name=True (and optionally validate_by_alias=True, serialize_by_alias=True) instead.
- UTC / timezone handling:
AwareDatetime coerces naive input by treating it as local time then converting to UTC; NaiveDatetime rejects tz-aware input. Date-only strings (YYYY-MM-DD) can produce surprising aware datetimes -- pin test cases (issue #8859). 2.12 added explicit timestamp-unit control (seconds vs ms) -- no more ambiguous inference.
model_dump() is shallow: mutating a nested custom-class object in the dumped dict mutates the original model instance. Use model.model_copy(deep=True).model_dump() if downstream code mutates (issue #10735).
Path and deque no longer accept constraints (2.11+): use a custom validator for length / pattern checks.
arbitrary_types_allowed=True is a pass-through: the attached value receives no validation. Necessary for stdlib types Pydantic doesn't know and third-party ORM rows; dangerous because it bypasses the safety model.
from __future__ import annotations (PEP 563): may require model_rebuild() or module-scope model definitions for forward refs to resolve. Pyright handles it cleanly; mypy may need the plugin.
- Mutable defaults: v2 deep-copies unhashable defaults per instance -- safer than stdlib dataclasses. Still prefer
Field(default_factory=list) for readability.
Integration
- Python architecture / API design that uses Pydantic v2 ->
python-development:python-engineer agent
- Testing Pydantic models ->
python-development:python-tdd skill
- FastAPI project scaffolding with v2 defaults ->
python-development:python-scaffold command (--type fastapi)
- Monetary precision patterns ->
senior-review:defect-taxonomy (CWE-681 / CWE-682)
- TypeScript equivalent (Zod / valibot) ->
typescript-development:mastering-typescript skill
References
Concept docs (live)
Release notes
Tooling and ecosystem
FastAPI
Known issues referenced above