Pydantic V2 Reference: BaseModel, Validators, Settings, Serialization & TypeAdapter
Pydantic V2 was rewritten in Rust and is 5-50x faster than V1. The API is similar but there are breaking changes: @validator is replaced by @field_validator, Config class becomes model_config, and .dict() becomes .model_dump(). Pydantic is used everywhere data needs to be validated and typed: FastAPI request/response models, settings management, dataclass replacement, and LLM structured outputs.
1. BaseModel, Field & Type Annotations
Define models, use Field() for constraints, and understand coercion vs strict mode
from pydantic import BaseModel, Field, EmailStr, HttpUrl
from typing import Annotated
from datetime import datetime
class User(BaseModel):
id: int
name: str = Field(min_length=1, max_length=100)
email: EmailStr # validates email format
website: HttpUrl | None = None # validates URL, optional
age: int = Field(ge=0, le=150) # ge=greater-equal, le=less-equal
score: float = Field(default=0.0, ge=0.0, le=1.0)
tags: list[str] = Field(default_factory=list)
created_at: datetime = Field(default_factory=datetime.utcnow)
# Annotated: reusable type alias with constraints:
PositiveInt = Annotated[int, Field(gt=0)]
Username = Annotated[str, Field(min_length=3, max_length=30, pattern=r"^[a-z0-9_]+$")]
class Profile(BaseModel):
user_id: PositiveInt
username: Username
# Instantiation + coercion (Pydantic coerces by default):
user = User(id="42", name="Alice", email="alice@example.com", age="30")
# id="42" → id=42 (str coerced to int)
# Strict mode: no coercion (int stays int, str stays str):
from pydantic import ConfigDict
class StrictUser(BaseModel):
model_config = ConfigDict(strict=True)
id: int # "42" would raise ValidationError
# model_config options:
class Config(BaseModel):
model_config = ConfigDict(
str_strip_whitespace=True, # strip leading/trailing whitespace
populate_by_name=True, # allow field name OR alias
from_attributes=True, # allow ORM objects (replaces orm_mode)
)
2. Validators — @field_validator & @model_validator
Field-level and model-level validators, before vs after mode, and @computed_field
from pydantic import BaseModel, field_validator, model_validator, computed_field
from typing import Self
class CreateUserRequest(BaseModel):
name: str
email: str
password: str
password_confirm: str
# @field_validator (replaces V1 @validator):
@field_validator("email")
@classmethod
def email_must_be_lowercase(cls, v: str) -> str:
return v.lower()
@field_validator("password")
@classmethod
def password_strength(cls, v: str) -> str:
if len(v) < 8:
raise ValueError("Password must be at least 8 characters")
if not any(c.isupper() for c in v):
raise ValueError("Password must contain at least one uppercase letter")
return v
# @model_validator: cross-field validation (after all fields are set):
@model_validator(mode="after")
def passwords_match(self) -> Self:
if self.password != self.password_confirm:
raise ValueError("Passwords do not match")
return self
# mode="before": receives raw input before type coercion:
@field_validator("name", mode="before")
@classmethod
def strip_and_title(cls, v):
if isinstance(v, str):
return v.strip().title()
return v
# @computed_field: property included in .model_dump() / .model_json():
class Product(BaseModel):
price: float
tax_rate: float = 0.2
@computed_field
@property
def price_with_tax(self) -> float:
return round(self.price * (1 + self.tax_rate), 2)
3. Serialization & Deserialization
model_dump(), model_dump_json(), model_validate(), aliases, and nested models
from pydantic import BaseModel, Field, AliasGenerator
from pydantic.alias_generators import to_camel
class Address(BaseModel):
street: str
city: str
zip_code: str
class UserResponse(BaseModel):
model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)
# to_camel: zip_code → zipCode, first_name → firstName
first_name: str
last_name: str
email_address: str
address: Address # nested model
# Serialization:
user = UserResponse(first_name="Alice", last_name="Smith",
email_address="alice@example.com",
address=Address(street="1 Main St", city="London", zip_code="EC1"))
user.model_dump() # {"first_name": "Alice", ...} (Python field names)
user.model_dump(by_alias=True) # {"firstName": "Alice", ...} (camelCase aliases)
user.model_dump(exclude={"address"}) # exclude fields
user.model_dump(include={"first_name", "email_address"}) # include only
user.model_dump(mode="json") # JSON-safe types (datetime → ISO string)
user.model_dump_json() # JSON string directly
# Deserialization from dict/JSON:
user = UserResponse.model_validate({"firstName": "Alice", ...}) # from alias
user = UserResponse.model_validate_json('{"firstName": "Alice", ...}') # from JSON string
# From ORM object (from_attributes=True):
class UserResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
user = UserResponse.model_validate(orm_user_object)
4. Settings Management (pydantic-settings)
Load config from .env files, environment variables, and multiple sources
# pip install pydantic-settings
from pydantic_settings import BaseSettings, SettingsConfigDict
from pydantic import SecretStr, PostgresDsn
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
extra="ignore", # ignore env vars not in model
)
# Required (no default — fails if not set):
database_url: PostgresDsn
secret_key: SecretStr # value hidden in repr/logs
# Optional with defaults:
debug: bool = False
port: int = 8000
allowed_origins: list[str] = ["http://localhost:3000"]
environment: str = "development"
# Nested settings (reads DATABASE__HOST etc.):
# model_config = SettingsConfigDict(env_nested_delimiter="__")
# .env file:
# DATABASE_URL=postgresql://user:pass@localhost/db
# SECRET_KEY=supersecret
# DEBUG=true
# ALLOWED_ORIGINS=["https://app.example.com","https://admin.example.com"]
settings = Settings()
print(settings.secret_key.get_secret_value()) # access SecretStr value
# Singleton pattern for FastAPI:
from functools import lru_cache
@lru_cache
def get_settings() -> Settings:
return Settings()
# In route: settings: Settings = Depends(get_settings)
5. Error Handling & Advanced Patterns
ValidationError, discriminated unions, TypeAdapter for non-model validation
from pydantic import BaseModel, ValidationError, TypeAdapter
from typing import Literal, Union, Annotated
# ValidationError: structured error with field paths:
try:
User(id="not-a-number", email="bad-email")
except ValidationError as e:
print(e.error_count()) # number of errors
for err in e.errors():
print(err["loc"]) # ("id",) or ("email",)
print(err["msg"]) # "Input should be a valid integer"
print(err["type"]) # "int_parsing"
# FastAPI automatically converts ValidationError to 422 response
# Discriminated union (efficient parsing when you have subtypes):
class Cat(BaseModel):
type: Literal["cat"]
indoor: bool
class Dog(BaseModel):
type: Literal["dog"]
breed: str
Pet = Annotated[Union[Cat, Dog], Field(discriminator="type")]
class PetOwner(BaseModel):
name: str
pet: Pet
owner = PetOwner(name="Alice", pet={"type": "dog", "breed": "Labrador"})
# pet is parsed as Dog, not Cat — O(1) lookup via type discriminator
# TypeAdapter: validate non-model types:
ta = TypeAdapter(list[int])
result = ta.validate_python(["1", "2", "3"]) # [1, 2, 3] (coerced)
ta.validate_python(["a", "b"]) # ValidationError
# TypeAdapter for JSON schema:
ta = TypeAdapter(list[User])
print(ta.json_schema())
Track Python and Pydantic releases at ReleaseRun. Related: FastAPI Reference | Python asyncio Reference | SQLAlchemy 2.0 Reference | Python EOL Tracker
🔍 Free tool: PyPI Package Health Checker — check Pydantic v1/v2, pydantic-settings, and email-validator for known CVEs and latest versions.
Founded
2023 in London, UK
Contact
hello@releaserun.com