← Back
April 4, 2026Python7 min read
Pydantic v2 is 5-50x faster — here's what changed and how to migrate
Published April 4, 20267 min read
I migrated a FastAPI service from Pydantic v1 to v2 last year. The API request validation went from 2.1ms to 0.4ms per request — a 5x speedup from one library upgrade. The migration took a day, mostly due to a handful of breaking changes that the migration guide glosses over. Here is the practical guide to the changes that actually catch people.
The performance numbers
Validation of a medium-complexity model: Pydantic v1: ~2.1ms per request Pydantic v2: ~0.4ms per request Improvement: 5x Serialization (model.dict()): Pydantic v1: ~1.8ms Pydantic v2 (model.model_dump()): ~0.1ms Improvement: 18x
Breaking change 1: model.dict() → model.model_dump()
python
from pydantic import BaseModel
class User(BaseModel):
id: int
name: str
email: str
user = User(id=1, name="Alice", email="alice@example.com")
# v1 (still works in v2 but deprecated)
user.dict()
# v2 (use this)
user.model_dump()
user.model_dump(exclude={'email'})
user.model_dump(include={'id', 'name'})
user.model_dump(mode='json') # Serializes non-JSON types like datetime
Breaking change 2: validators are now @field_validator
python
from pydantic import BaseModel, field_validator, model_validator
# v1 syntax (deprecated in v2)
class UserV1(BaseModel):
email: str
@validator('email')
def email_must_be_lowercase(cls, v):
return v.lower()
# v2 syntax
class UserV2(BaseModel):
email: str
password: str
@field_validator('email')
@classmethod
def email_must_be_lowercase(cls, v: str) -> str:
return v.lower()
# Root validator equivalent
@model_validator(mode='after')
def check_password_strength(self) -> 'UserV2':
if len(self.password) < 8:
raise ValueError('Password must be at least 8 characters')
return self
Breaking change 3: Field() has new parameters
python
from pydantic import BaseModel, Field
class Product(BaseModel):
name: str = Field(..., min_length=1, max_length=200)
price_cents: int = Field(..., gt=0)
# v1: Field(regex="...")
# v2: Field(pattern="...")
sku: str = Field(..., pattern=r'^[A-Z]{3}-d{6}$')
# v1: Field(env="DATABASE_URL") -- BaseSettings only
# v2: still works in pydantic-settings
# v1: const=True for literal fields
# v2: use Literal type
# type: Literal['product'] = 'product' # Not const=True
Breaking change 4: ORM mode → from_attributes
python
from pydantic import BaseModel
# v1
class UserResponseV1(BaseModel):
id: int
name: str
class Config:
orm_mode = True
# v2
class UserResponse(BaseModel):
id: int
name: str
model_config = ConfigDict(from_attributes=True)
# In FastAPI endpoints:
from pydantic import ConfigDict
class UserOut(BaseModel):
id: int
name: str
model_config = ConfigDict(from_attributes=True)
@app.get("/users/{user_id}", response_model=UserOut)
async def get_user(user_id: int, db: Session = Depends(get_db)):
user = db.query(UserModel).filter(UserModel.id == user_id).first()
return UserOut.model_validate(user) # v2 equivalent of from_orm()
Breaking change 5: Required vs Optional
python
from pydantic import BaseModel
from typing import Optional
# v1: Optional[str] meant "not required, defaults to None"
class UserV1(BaseModel):
name: Optional[str] # v1: defaults to None, not required
# v2: Optional[str] means "can be str or None, but REQUIRED"
class UserV2(BaseModel):
name: Optional[str] # v2: REQUIRED but can be None
name_with_default: Optional[str] = None # v2: not required, defaults to None
# Explicit required vs optional in v2
class UserV2Explicit(BaseModel):
required_name: str # Required
optional_name: str | None = None # Not required (v2 style)
nullable_required: str | None # Required, but can be None
The migration script
bash
# Automated migration (handles most cases)
pip install bump-pydantic
bump-pydantic --path ./src
# Then manually fix:
# 1. Optional[T] fields without defaults (add = None or make explicit)
# 2. Complex validators that use pre=True (now mode='before')
# 3. Custom __get_validators__ (now __get_pydantic_core_schema__)
# 4. __fields__ access (now model_fields)
The migration is worth it. Beyond the 5-50x performance gain, v2's error messages are clearer, the type system is stricter (catches more bugs at development time), and the Rust core makes the library more maintainable long-term. The Optional[T] change is the one that bites almost every migration — audit every Optional field for whether it should require a default or stay required.
Share this
← All Posts7 min read