| name | pydantic |
| description | This skill should be used when the user asks to "validate data with pydantic", "create a pydantic model", "use pydantic best practices", "write pydantic validators", or needs guidance on pydantic v2 patterns, serialization, configuration, or performance optimization. |
Pydantic v2 Best Practices
Pydantic is the most widely used data validation library for Python. This skill covers idiomatic patterns, common pitfalls, and performance guidance for Pydantic v2 (the current major version).
Models
Define models by inheriting from BaseModel
from pydantic import BaseModel, ConfigDict
class User(BaseModel):
model_config = ConfigDict(str_strip_whitespace=True, extra='forbid')
id: int
name: str
email: str | None = None
Key rules:
- Use
model_config = ConfigDict(...) — never the deprecated V1 class Config.
- Set
extra='forbid' to reject unexpected fields in strict APIs; use extra='ignore' (default) or extra='allow' when appropriate.
- Avoid naming a field the same as its type annotation (
int: int breaks validation).
Validate data correctly
user = User.model_validate({'id': 1, 'name': 'Alice'})
user = User.model_validate_json('{"id": 1, "name": "Alice"}')
class UserORM: ...
user = User.model_validate(orm_obj, from_attributes=True)
Always prefer model_validate_json() over model_validate(json.loads(...)) for JSON input — the former validates internally without an extra Python-side parse step.
Use model_post_init instead of a custom __init__
from typing import Any
from pydantic import BaseModel
class MyModel(BaseModel):
value: int
def model_post_init(self, context: Any) -> None:
self._cache: dict = {}
Defining a custom __init__ bypasses validation parameters (strictness, extra, context). Use model_post_init for side effects after initialization.
Copy models with model_copy
updated = user.model_copy(update={'name': 'Bob'})
deep_copy = user.model_copy(deep=True)
Fields
Use the Annotated pattern for reusable constraints
from typing import Annotated
from pydantic import BaseModel, Field
PositivePrice = Annotated[float, Field(gt=0, description='Price in USD')]
ShortString = Annotated[str, Field(max_length=100)]
class Product(BaseModel):
name: ShortString
price: PositivePrice
quantity: Annotated[int, Field(ge=0)] = 0
The annotated pattern makes constraints composable and reusable across models, unlike field: type = Field(...) which ties the constraint to one model.
Provide field metadata for JSON Schema
from pydantic import BaseModel, Field
class Article(BaseModel):
title: str = Field(
min_length=1,
max_length=200,
title='Article Title',
description='The main headline',
examples=['Pydantic v2 released'],
)
Use default_factory for mutable defaults
from pydantic import BaseModel, Field
class Order(BaseModel):
items: list[str] = Field(default_factory=list)
tags: set[str] = Field(default_factory=set)
Pydantic handles non-hashable defaults (like [], {}) safely by deep-copying them, but default_factory is the explicit, recommended approach.
Use aliases to decouple field names from wire formats
from pydantic import BaseModel, Field, ConfigDict
class Response(BaseModel):
model_config = ConfigDict(populate_by_name=True)
user_id: int = Field(alias='userId')
created_at: str = Field(serialization_alias='createdAt')
Validators
Choose the right validator mode
| Mode | When to use |
|---|
after | Post-type-coercion checks; input is already the correct type |
before | Pre-coercion transformations; input may be raw/arbitrary |
plain | Full replacement of Pydantic's logic for a field |
wrap | Need to intercept errors or run code both before and after |
Prefer after validators — they receive the already-coerced value and are easier to type correctly.
Write reusable validators with the annotated pattern
from typing import Annotated
from pydantic import AfterValidator, BaseModel
def must_be_even(v: int) -> int:
if v % 2 != 0:
raise ValueError(f'{v} is not even')
return v
EvenInt = Annotated[int, AfterValidator(must_be_even)]
class Config(BaseModel):
batch_size: EvenInt
worker_count: EvenInt
Use field_validator to apply one function to multiple fields
from pydantic import BaseModel, field_validator
class User(BaseModel):
first_name: str
last_name: str
@field_validator('first_name', 'last_name', mode='before')
@classmethod
def strip_whitespace(cls, v: str) -> str:
return v.strip()
Use model_validator for cross-field checks
from typing_extensions import Self
from pydantic import BaseModel, model_validator
class DateRange(BaseModel):
start: int
end: int
@model_validator(mode='after')
def check_range(self) -> Self:
if self.end <= self.start:
raise ValueError('end must be greater than start')
return self
Raise the right exception type in validators
ValueError — standard choice for most validation failures.
AssertionError — works but is skipped under Python's -O flag; avoid in production validators.
PydanticCustomError — use when custom error types and structured error metadata are needed.
from pydantic_core import PydanticCustomError
raise PydanticCustomError(
'invalid_format',
'Value {value!r} does not match the expected format',
{'value': v},
)
Pass context to validators when needed
from pydantic import BaseModel, ValidationInfo, field_validator
class Document(BaseModel):
text: str
@field_validator('text', mode='after')
@classmethod
def filter_words(cls, v: str, info: ValidationInfo) -> str:
if isinstance(info.context, dict):
banned = info.context.get('banned_words', set())
v = ' '.join(w for w in v.split() if w not in banned)
return v
doc = Document.model_validate(
{'text': 'hello world'},
context={'banned_words': {'hello'}},
)
Error Handling
Catch ValidationError and inspect .errors() for structured detail:
from pydantic import BaseModel, ValidationError
class Item(BaseModel):
price: float
quantity: int
try:
Item(price='bad', quantity=-1)
except ValidationError as exc:
for error in exc.errors():
print(error['loc'], error['msg'], error['type'])
One ValidationError aggregates all field errors — never raised per-field individually.
Quick Reference
| Task | Recommended API |
|---|
| Validate from dict | Model.model_validate(data) |
| Validate from JSON | Model.model_validate_json(json_str) |
| Validate from ORM | Model.model_validate(obj, from_attributes=True) |
| Dump to dict | model.model_dump() |
| Dump to JSON | model.model_dump_json() |
| Dump only set fields | model.model_dump(exclude_unset=True) |
| Copy with changes | model.model_copy(update={...}) |
| Skip validation | Model.model_construct(...) — only for pre-validated data |
| Rebuild after forward refs | Model.model_rebuild() |
Additional Resources
references/validators-and-fields.md — Detailed validator modes, field constraints, discriminated unions, and computed fields.
references/serialization-and-config.md — Serializers, model_dump options, ConfigDict reference, and ORM integration.
references/performance.md — Performance tips: TypeAdapter reuse, tagged unions, TypedDict vs nested models, FailFast, and more.