| name | pydantic |
| description | Use when defining request/response schemas, writing custom validators, controlling serialization for PATCH endpoints, validating non-model data with TypeAdapter, or configuring app settings from environment variables with pydantic-settings. |
Pydantic v2 Patterns
Validation, serialization, and settings management with Pydantic v2.
When to Activate
- Defining request/response schemas or domain models
- Writing
@field_validator or @model_validator for custom validation
- Using
Annotated to build reusable constrained types
- Controlling serialization with
model_dump() / model_dump_json()
- Building generic models or discriminated unions
- Validating arbitrary data (not a model) with
TypeAdapter
- Configuring app settings from environment variables with
pydantic-settings
BaseModel Basics
from pydantic import BaseModel, Field
from datetime import datetime
from uuid import UUID
class User(BaseModel):
id: UUID
name: str
email: str
age: int = Field(ge=0, le=150)
role: str = "user"
created_at: datetime | None = None
user = User(id="a1b2...", name="Alice", email="alice@example.com", age=30)
user.name
user.model_fields
user = User.model_validate({"id": "...", "name": "Alice", ...})
user = User.model_validate_json('{"id": "...", "name": "Alice", ...}')
Field Constraints
from pydantic import BaseModel, Field
from typing import Annotated
class Product(BaseModel):
name: str = Field(min_length=1, max_length=200, strip_whitespace=True)
price: float = Field(gt=0, description="Price in USD")
discount: float = Field(ge=0, le=1, default=0.0)
tags: list[str] = Field(default_factory=list, max_length=10)
sku: str = Field(pattern=r"^[A-Z]{3}-\d{6}$")
metadata: dict = Field(default_factory=dict)
name: str = Field(alias="product_name")
Reusable constrained types with Annotated
from typing import Annotated
from pydantic import Field
PositiveInt = Annotated[int, Field(gt=0)]
Percentage = Annotated[float, Field(ge=0.0, le=1.0)]
NonEmptyStr = Annotated[str, Field(min_length=1, strip_whitespace=True)]
EmailStr = Annotated[str, Field(pattern=r"^[^@]+@[^@]+\.[^@]+$")]
UserId = Annotated[str, Field(min_length=36, max_length=36)]
class CreateUserRequest(BaseModel):
name: NonEmptyStr
email: EmailStr
age: PositiveInt
discount: Percentage = 0.0
Validators
@field_validator — validate / transform a single field
from pydantic import BaseModel, field_validator
class User(BaseModel):
name: str
email: str
role: str
@field_validator("email")
@classmethod
def lowercase_email(cls, v: str) -> str:
return v.strip().lower()
@field_validator("role")
@classmethod
def valid_role(cls, v: str) -> str:
allowed = {"admin", "user", "viewer"}
if v not in allowed:
raise ValueError(f"role must be one of {allowed}")
return v
@field_validator("name", "email", mode="before")
@classmethod
def strip_strings(cls, v: str) -> str:
return v.strip() if (v, ) v
mode="before" runs before type coercion. mode="after" (default) runs after.
@model_validator — validate across multiple fields
from pydantic import BaseModel, model_validator
class DateRange(BaseModel):
start_date: datetime
end_date: datetime
max_days: int = 90
@model_validator(mode="after")
def check_date_range(self) -> "DateRange":
if self.end_date <= self.start_date:
raise ValueError("end_date must be after start_date")
delta = (self.end_date - self.start_date).days
if delta > self.max_days:
raise ValueError(f"Range cannot exceed {self.max_days} days")
return self
class PasswordReset(BaseModel):
password: str
confirm_password: str
@model_validator(mode="after")
def passwords_match(self) -> "PasswordReset":
if self.password != self.confirm_password:
raise ValueError("Passwords do not match")
return self
() -> :
data:
data[] = data.pop()
data
ConfigDict
from pydantic import BaseModel, ConfigDict
class UserResponse(BaseModel):
model_config = ConfigDict(
from_attributes=True,
populate_by_name=True,
str_strip_whitespace=True,
str_to_lower=False,
extra="forbid",
frozen=True,
arbitrary_types_allowed=True,
json_schema_extra={"example": {"name": "Alice", "email": "alice@example.com"}},
)
Serialization
user = User(id=uuid4(), name="Alice", email="alice@example.com", role="admin")
user.model_dump()
user.model_dump(exclude={"password", "internal_id"})
user.model_dump(include={"id", "name", "email"})
user.model_dump(exclude_none=True)
user.model_dump(exclude_unset=True)
user.model_dump(by_alias=True)
user.model_dump(mode="json")
user.model_dump_json()
user.model_dump_json(indent=2, exclude_none=True)
orm_user = db.query(UserORM).first()
user = UserResponse.model_validate(orm_user)
updated = user.model_copy(update={"role": "admin"})
Discriminated Unions
from pydantic import BaseModel
from typing import Literal, Union, Annotated
from pydantic import Field
class CreditCard(BaseModel):
type: Literal["credit_card"]
number: str
expiry: str
cvv: str
class BankTransfer(BaseModel):
type: Literal["bank_transfer"]
account_number: str
routing_number: str
class Crypto(BaseModel):
type: Literal["crypto"]
wallet_address: str
currency: str
PaymentMethod = Annotated[
Union[CreditCard, BankTransfer, Crypto],
Field(discriminator="type"),
]
class Order(BaseModel):
id: str
payment: PaymentMethod
order = Order.model_validate({
"id": "o-123",
"payment": {"type": "credit_card", "number": , : , : },
})
(order.payment, CreditCard)
Generic Models
from pydantic import BaseModel
from typing import TypeVar, Generic
T = TypeVar("T")
class Page(BaseModel, Generic[T]):
items: list[T]
total: int
page: int
page_size: int
has_next: bool
class ApiResponse(BaseModel, Generic[T]):
data: T
status: int = 200
message: str = "ok"
users_page: Page[User] = Page[User](items=[...], total=100, page=1, page_size=20, has_next=True)
response: ApiResponse[User] = ApiResponse[User](data=user)
TypeAdapter — validate without a model
from pydantic import TypeAdapter
ta = TypeAdapter(list[int])
ta.validate_python([1, 2, "3"])
ta.validate_json("[1, 2, 3]")
ta = TypeAdapter(dict[str, list[int]])
ta.validate_python({"a": [1, 2], "b": [3]})
StrippedStr = Annotated[str, Field(strip_whitespace=True, min_length=1)]
ta = TypeAdapter(StrippedStr)
ta.validate_python(" hello ")
pydantic-settings
from pydantic_settings import BaseSettings, SettingsConfigDict
from pydantic import Field
from functools import lru_cache
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
extra="ignore",
)
database_url: str
secret_key: str
environment: str = "development"
debug: bool = False
redis_url: str = "redis://localhost:6379"
allowed_origins: list[str] = ["http://localhost:3000"]
temporal_address: str = Field("localhost:7233", alias="TEMPORAL_ADDRESS")
@property
def is_production(self) -> bool:
return self.environment == "production"
@lru_cache
def get_settings() -> Settings:
return Settings()
settings = get_settings()
Env var names match field names case-insensitively. list[str] reads from ALLOWED_ORIGINS=http://a.com,http://b.com (comma-separated).
Red Flags
- Sharing API schemas with the domain layer — using the same Pydantic model as both the HTTP request schema and the internal domain entity couples the API contract to business logic; changes to the API surface silently affect domain behavior and vice versa
- Mutable field defaults without
default_factory — tags: list[str] = [] shares the same list object across all instances; use tags: list[str] = Field(default_factory=list) for any mutable default
- Not using
model_dump(exclude_unset=True) for PATCH — model_dump() on a partial-update model includes all fields set to their defaults, overwriting database values the client never sent; exclude_unset=True returns only the fields the caller explicitly provided
orm_mode = True (v1 syntax) in a v2 project — the v1 config key is silently ignored in Pydantic v2; use model_config = ConfigDict(from_attributes=True) instead
- Catching bare
Exception from model_validate — validation errors from Pydantic are ValidationError, not ValueError or Exception; catching the wrong type means bad input crashes the caller with an unhandled exception instead of a structured error response
model_dump() when JSON-safe types are needed — model_dump() returns Python objects (UUID, datetime, Decimal) that are not JSON-serializable; use model_dump(mode="json") or model_dump_json() when the result will be serialized to JSON or stored as a dict in MongoDB
- Repeating
Field(gt=0) on every model instead of Annotated types — duplicating constraints is error-prone and hard to update; define PositiveInt = Annotated[int, Field(gt=0)] once and reuse it everywhere
Checklist