plugins/python-development/skills/pydantic-v2/SKILL.md
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).
npx skillsauth add acaprino/alfio-claude-plugins pydantic-v2Install this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
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:
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).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).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).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,
)
# Type aliases with constraints -- preferred in v2 over `constr()` / `conint()`
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 replaces inner `Config` class
model_config = ConfigDict(
strict=True, # refuse string "1" for int field
frozen=True, # hashable + immutable
populate_by_name=True, # accept alias AND field name
str_strip_whitespace=True, # strip on all str fields
extra="forbid", # reject unknown keys
)
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
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.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.| 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).
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:
# info.data: already-validated fields (in declaration order -- cannot access later fields)
# info.context: user-supplied dict via Model.model_validate(data, context={...})
# info.mode: "python" | "json" | "strings"
# info.field_name: the field being validated
if len(v) < 12:
raise ValueError("password must be at least 12 chars")
return v
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)]
# Reuse across any model
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.
Stable API across 2.x. Context passes through to custom serializers (2.7+):
user = User.model_validate(payload, context={"tenant_id": request.tenant_id})
# Inside a field_validator / model_validator / field_serializer:
# info.context["tenant_id"] is available
Use context for tenant-aware validation, feature flags, or bypass switches -- NOT for data that should be on the model.
@model_validator(mode="before")
@classmethod
def normalize_input(cls, data: dict) -> dict:
# mode="before": classmethod, first arg cls, receives raw input (often dict)
# Do NOT mutate then raise -- mutations leak to downstream validators
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":
# mode="after": INSTANCE method (not classmethod; @classmethod deprecated in 2.12)
# returns self, not a dict
if self.start_date > self.end_date:
raise ValueError("start_date must be <= end_date")
return self
from pydantic_core import PydanticCustomError
@field_validator("sku")
@classmethod
def sku_format(cls, v: str) -> str:
if not v.isalnum():
raise PydanticCustomError(
"invalid_sku", # machine-readable error type
"{field} must be alphanumeric", # template (formatted by Pydantic)
{"field": "sku"}, # context dict
)
return v
ValueError auto-wraps into a Pydantic error -- simplest path.assert -- skipped under python -O.loc (error location) is determined by validator position; not user-editable at raise site.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.
model_dump, model_dump_json, model_validatev2 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.
model.model_dump(mode="python") # native Python types (Decimal stays Decimal)
model.model_dump(mode="json") # JSON-compatible types (Decimal -> str, UUID -> str, datetime -> ISO)
model.model_dump(mode="strings") # everything coerced to str (CSV / form encodings)
model.model_dump(exclude={"password_hash"})
model.model_dump(include={"id", "name"})
model.model_dump(exclude_none=True)
model.model_dump(by_alias=True) # use field aliases as keys
model.model_dump(round_trip=True) # output can be fed back into model_validate
# 2.13+ polymorphic: subclass instances serialize by their actual type, exposing subclass-only fields
model.model_dump(polymorphic_serialization=True)
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
# Per-field decorator
@field_serializer("total", when_used="json")
def serialize_total(self, v: Decimal) -> str:
return f"${v:.2f}"
# Whole-model wrap: call handler() for default output, then post-process
@model_serializer(mode="wrap")
def add_envelope(self, handler: SerializerFunctionWrapHandler) -> dict:
return {"version": 1, "data": handler(self)}
# Reusable Annotated serializers
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".
model_json_schema(mode="validation" | "serialization") returns different shapes:
Model.model_json_schema(mode="validation") # request shape -- computed_fields absent, input-only aliases
Model.model_json_schema(mode="serialization") # response shape -- computed_fields required, output aliases
FastAPI uses both: validation mode for request schemas, serialization mode for response schemas. Pydantic v2 targets OpenAPI 3.1 natively.
| 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, # accept aliases in input
validate_by_name=True, # also accept field names in input
serialize_by_alias=True, # emit aliases in output
)
user_id: str = Field(alias="userId")
populate_by_name still works but is pending v3 deprecation -- migrate now.
For hot paths, skip the Python dict materialization:
# Standard
raw = model.model_dump_json() # already faster than json.dumps(model.model_dump())
# Fast-path: bytes directly from Rust
raw_bytes = MyModel.__pydantic_serializer__.to_json(model)
Cache __pydantic_serializer__ at module scope for ultra-hot paths (SSE, WebSocket, bulk export).
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.
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.
Never use float for money. Two idiomatic v2 options:
from decimal import Decimal
from typing import Annotated
from pydantic import BaseModel, Field, condecimal
# Option A (preferred): Annotated with Field constraints
USDAmount = Annotated[Decimal, Field(max_digits=14, decimal_places=2, ge=0)]
# Option B: condecimal() wrapper (still supported)
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 # banker's rounding for financial totals
See senior-review:defect-taxonomy CWE-681 / CWE-682 for the full monetary-precision rules.
pydantic-settings)v2 extracted settings into a separate package pydantic-settings:
# pip install 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 # redacted in repr / model_dump_json
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 tokensettings.stripe_secret_key.get_secret_value()from fastapi import FastAPI
from pydantic import BaseModel
class UserOut(BaseModel):
id: int
email: str
class UserDB(UserOut):
password_hash: str # server-side only, never exposed
app = FastAPI()
@app.get("/users/{user_id}", response_model=UserOut)
async def get_user(user_id: int) -> UserDB:
# Returning the broader UserDB is fine; FastAPI projects through UserOut.
return await fetch_user(user_id)
FastAPI uses the response_model for serialization, which strips password_hash. Prefer this to manual dict construction.
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(), # Pydantic v2 error format
"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.
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) # bytes
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.TypeAdapter at module scope, never inside request handlers. Each instantiation rebuilds validators.list[int] beats Sequence[int]; dict beats Mapping.TypedDict via TypeAdapter is ~2.5x faster than an equivalent nested BaseModel for pure-data shapes.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.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.
SecretStr / SecretBytesfrom pydantic import BaseModel, SecretStr
class Credentials(BaseModel):
api_key: SecretStr
c = Credentials(api_key="sk-real-secret-123")
print(c) # api_key=SecretStr('**********')
print(c.model_dump_json()) # {"api_key":"**********"}
c.api_key.get_secret_value() # the real value, use only at the trusted sink
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().
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.mode="before" validator that counts.model_dump() output alias back to the model (shallow copy) -- see gotcha above.Two first-party tools worth knowing when building LLM-driven features:
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).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()BaseModel[T] -- v2 uses native Python generics, not GenericModel; PEP 695 syntax supported since 2.11Field(..., 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 itsmart_union -> default behavior in v2; old smart_union=True is a no-opValidationError.errors() format changed: msg, type, loc, input, ctx, url -- regenerate any error-envelope testsparse_obj_as(list[Foo], data) -> TypeAdapter(list[Foo]).validate_python(data) (instantiate adapter at module scope)bump-pydantic missesRequires manual conversion:
__get_validators__ / __modify_schema__ -> __get_pydantic_core_schema__ + __get_pydantic_json_schema__each_item=True validators on sequence fieldsjson_encoders in Config -> @field_serializer or Annotated PlainSerializer@validator(always=True) on computed fields -> @computed_fieldAlways diff the changes manually after running bump-pydantic.
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).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.Literal, not str: Field(discriminator="type") requires each union member to declare type: Literal["..."], not type: str.__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.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.Field(default_factory=list) for readability.python-development:python-engineer agentpython-development:python-tdd skillpython-development:python-scaffold command (--type fastapi)senior-review:defect-taxonomy (CWE-681 / CWE-682)typescript-development:mastering-typescript skilltools
Master memory forensics techniques including memory acquisition, process analysis, and artifact extraction using Volatility and related tools. Use when analyzing memory dumps, investigating incidents, or performing malware analysis from RAM captures.
development
Master binary analysis patterns including disassembly, decompilation, control flow analysis, and code pattern recognition. Use when analyzing executables, understanding compiled code, or performing static analysis on binaries.
development
Idiomatic Kotlin implementation patterns: coroutines and structured concurrency, Flow / StateFlow / SharedFlow, Kotlin Multiplatform (KMP) shared-code architecture, Jetpack Compose UI, Ktor server with JWT auth and Exposed, and type-safe DSL design (lambdas with receivers, delegated properties, inline reified, value classes). TRIGGER WHEN: building, writing, or reviewing Kotlin code using coroutines / Flow / suspend functions, expect/actual, Compose composables / ViewModels, Ktor routing, sealed-class state modeling, scope functions, or DSL builders. DO NOT TRIGGER WHEN: libGDX game work (use libgdx-development), Android Java without Kotlin, or pure JVM tuning unrelated to Kotlin language features.
tools
Strategic website planning skill that conducts structured client discovery, produces professional deliverables (website brief, sitemap, design direction, content strategy), and orchestrates frontend-design, frontend-layout, seo-specialist, and content-marketer agents automatically. TRIGGER WHEN: planning a new website or redesign before any code is written. DO NOT TRIGGER WHEN: the task is outside the specific scope of this component.