⏱️5 min read · 1,057 words
Pydantic v2 is the most important Python library upgrade in 2026. Rewritten in Rust (via pydantic-core), it is 5-50x faster than v1 with a cleaner API, strict mode, and model validators. This guide covers every Pydantic v2 feature with practical examples.
📋 Table of Contents
Installation and Migration
pip install pydantic>=2.0
# Auto-migrate from v1
pip install bump-pydantic
bump-pydantic .
# Key changes from v1:
# - class Config -> model_config = ConfigDict(...)
# - @validator -> @field_validator + @model_validator
# - .dict() -> .model_dump()
# - .json() -> .model_dump_json()
# - __fields__ -> model_fields
Basic Models
from pydantic import BaseModel, Field, ConfigDict, EmailStr
from datetime import datetime
from typing import Optional
class User(BaseModel):
model_config = ConfigDict(
str_strip_whitespace=True, # strip whitespace from strings
str_min_length=1, # no empty strings
validate_assignment=True, # validate on attribute assignment
populate_by_name=True, # allow field name OR alias
use_enum_values=True, # store enum.value not enum
)
id: int
name: str = Field(min_length=2, max_length=50)
email: EmailStr
age: Optional[int] = Field(default=None, ge=13, le=120)
role: str = Field(default="user", pattern=r"^(user|admin|moderator)$")
created_at: datetime = Field(default_factory=datetime.utcnow)
tags: list[str] = Field(default_factory=list, max_length=10)
# Create
user = User(id=1, name="Alice Chen", email="alice@example.com", age=30)
print(user.model_dump())
print(user.model_dump_json(indent=2))
# Partial update
updated = user.model_copy(update={"name": "Alice Smith"})
# From dict
user2 = User.model_validate({"id": 2, "name": "Bob", "email": "bob@example.com"})
# From JSON string
user3 = User.model_validate_json('{"id": 3, "name": "Carol", "email": "carol@example.com"}')
Validators
from pydantic import BaseModel, field_validator, model_validator, Field
import re
class PasswordChange(BaseModel):
current_password: str
new_password: str = Field(min_length=8)
confirm_password: str
@field_validator("new_password")
@classmethod
def password_strength(cls, v: str) -> str:
if not re.search(r"[A-Z]", v):
raise ValueError("Must contain uppercase letter")
if not re.search(r"[0-9]", v):
raise ValueError("Must contain digit")
if not re.search(r"[!@#$%^&*]", v):
raise ValueError("Must contain special character")
return v
@model_validator(mode="after") # runs after all field validators
def passwords_match(self) -> "PasswordChange":
if self.new_password != self.confirm_password:
raise ValueError("Passwords do not match")
if self.new_password == self.current_password:
raise ValueError("New password must differ from current")
return self
# Validate and get all errors at once
from pydantic import ValidationError
try:
PasswordChange(
current_password="old123",
new_password="short",
confirm_password="different"
)
except ValidationError as e:
print(e.error_count(), "errors")
for error in e.errors():
print(f" {error['loc']}: {error['msg']}")
Strict Mode
from pydantic import BaseModel, ConfigDict
class StrictUser(BaseModel):
model_config = ConfigDict(strict=True) # no coercion!
id: int
name: str
active: bool
# Without strict: "1" coerced to int, "true" to bool
# With strict: exact types required
try:
StrictUser(id="1", name="Alice", active="true") # raises!
except Exception as e:
print(e)
# Strict per-field
from pydantic import Strict
from typing import Annotated
class PartiallyStrict(BaseModel):
id: Annotated[int, Strict()] # strict for this field only
name: str # coercion allowed
Aliases and Serialization
from pydantic import BaseModel, Field, AliasPath
from pydantic.functional_serializers import model_serializer
class APIResponse(BaseModel):
# Accept camelCase from API, use snake_case internally
model_config = ConfigDict(populate_by_name=True)
user_id: int = Field(alias="userId")
first_name: str = Field(alias="firstName")
last_name: str = Field(alias="lastName")
created_at: str = Field(alias="createdAt")
# Nested alias path
city: str = Field(validation_alias=AliasPath("address", "city"))
# Parse camelCase JSON
data = {
"userId": 1,
"firstName": "Alice",
"lastName": "Chen",
"createdAt": "2026-01-15",
"address": {"city": "Sydney", "country": "AU"}
}
response = APIResponse.model_validate(data)
print(response.user_id, response.first_name, response.city)
# Serialize back to camelCase
print(response.model_dump(by_alias=True)) # {"userId": 1, ...}
Pydantic + FastAPI (2026 Pattern)
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field, ConfigDict
from typing import Annotated
from datetime import datetime
app = FastAPI()
class UserCreate(BaseModel):
name: Annotated[str, Field(min_length=2, max_length=50)]
email: Annotated[str, Field(pattern=r"^[^@]+@[^@]+\.[^@]+$")]
password: Annotated[str, Field(min_length=8, exclude=True)] # exclude from response
class UserResponse(BaseModel):
model_config = ConfigDict(from_attributes=True) # allows ORM model input
id: int
name: str
email: str
created_at: datetime
@classmethod
def from_orm_with_extras(cls, obj, **extras) -> "UserResponse":
return cls.model_validate({**obj.__dict__, **extras})
@app.post("/users", response_model=UserResponse, status_code=201)
async def create_user(data: UserCreate) -> UserResponse:
# data.password available but won't appear in response
user = await db.users.create({
"name": data.name,
"email": data.email,
"password_hash": hash_password(data.password)
})
return UserResponse.model_validate(user)
# Automatic OpenAPI docs show the schema
# Pydantic v2 JSON Schema generation is much better than v1
Performance: v1 vs v2
import timeit
from pydantic import BaseModel
class BenchModel(BaseModel):
id: int
name: str
email: str
score: float
data = {"id": 1, "name": "Alice", "email": "alice@example.com", "score": 9.5}
n = 100_000
t = timeit.timeit(lambda: BenchModel(**data), number=n)
print(f"Pydantic v2: {t:.2f}s for {n:,} validations ({n/t:,.0f}/sec)")
# Typical: ~2-5x faster than v1, up to 50x for complex nested models
Pydantic v2 in 2026 is the validation standard for Python. The Rust core delivers significant performance gains. Field validators catch data issues early, model validators enforce cross-field constraints, and strict mode eliminates silent coercion bugs. Migrate from v1 using bump-pydantic — it handles 90% of changes automatically.
📚 You might also like
🔗 Share this article




✍️ Leave a Comment