Pydantic v2 is 5-50x faster — here's what changed and how to migrate
← 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