Pydantic v2 Broke Everything (And It Was Worth It)
We finally migrated our codebase at work from Pydantic v1 to v2. It was painful. We broke things. We fixed things. We questioned our life choices. But the performance gains are real and the new API is actually better once you get used to it.
Here's what we learned from the migration.
Why we had to migrate
Pydantic v1 was already marked as deprecated, and new libraries were starting to require v2. We couldn't stay on v1 forever.
Also: v2 is way faster. It's written in Rust (the core validation logic is compiled to native code via pydantic-core). For an API that does a lot of serialization, this matters.
But the real catalyst? We tried to upgrade FastAPI to 0.100+ and hit a wall. Modern versions of FastAPI, SQLModel, and a bunch of other libraries in the ecosystem now require or strongly prefer Pydantic v2. We were stuck unable to use new features and security patches. That's when we knew we had to bite the bullet.
The Python ecosystem moves fast. If you're maintaining production code on v1, you're not just missing out on performance - you're accumulating technical debt that will be harder to pay off later. Libraries are dropping v1 support, documentation is shifting to v2 examples, and Stack Overflow answers are increasingly v2-focused.
What broke: the big stuff
Validators completely changed syntax:
# v1 - this no longer works
@validator('email')
def validate_email(cls, v):
return v.lower()
# v2 - new syntax
@field_validator('email')
@classmethod
def validate_email(cls, v):
return v.lower()
Note the @classmethod decorator - that's required now. Also, @validator became @field_validator, and there's a new @model_validator for whole-model validation.
The @model_validator is particularly useful for cross-field validation:
from pydantic import model_validator
class PasswordReset(BaseModel):
password: str
password_confirm: str
@model_validator(mode='after')
def check_passwords_match(self) -> 'PasswordReset':
if self.password != self.password_confirm:
raise ValueError('Passwords do not match')
return self
In v1, you'd do this with a root validator, but the syntax was more awkward. The mode='after' parameter means it runs after field validation - you can also use mode='before' for preprocessing raw data.
Config classes are gone:
# v1 - nested Config class
class User(BaseModel):
name: str
class Config:
orm_mode = True
allow_population_by_field_name = True
# v2 - model_config attribute
class User(BaseModel):
model_config = ConfigDict(
from_attributes=True,
populate_by_name=True
)
name: str
The setting names changed too. orm_mode became from_attributes, allow_population_by_field_name became populate_by_name. There's a whole mapping in the docs.
.dict() and .json() renamed:
# v1
user.dict()
user.json()
# v2
user.model_dump()
user.model_dump_json()
This broke approximately one million lines of code across our projects. Every test that checked .dict() output. Every API route that called .json(). The old names still work but show deprecation warnings.
What broke: the subtle stuff
Field defaults are handled differently:
# v1 - this was fine
class Thing(BaseModel):
items: list = []
# v2 - must use default_factory
class Thing(BaseModel):
items: list = Field(default_factory=list)
v2 is stricter about mutable defaults. Makes sense - sharing a list across instances is a classic Python footgun.
Strict mode is stricter:
v2 doesn't coerce types as aggressively by default. If you were relying on '123' being coerced to 123 for an int field, you might need to add explicit strict=False or handle it differently.
Custom types need updating:
If you had custom types with __get_validators__, that API changed. Now you implement __get_pydantic_core_schema__. It's more powerful but more verbose.
Here's a real example we had to migrate:
# v1 - custom type for normalized phone numbers
class PhoneNumber(str):
@classmethod
def __get_validators__(cls):
yield cls.validate
@classmethod
def validate(cls, v):
if not isinstance(v, str):
raise TypeError('string required')
# Remove all non-numeric characters
cleaned = ''.join(filter(str.isdigit, v))
if len(cleaned) != 10:
raise ValueError('Phone number must be 10 digits')
return cls(cleaned)
# v2 - requires core schema implementation
from pydantic_core import core_schema
from typing import Any
class PhoneNumber(str):
@classmethod
def __get_pydantic_core_schema__(
cls, source_type: Any, handler
) -> core_schema.CoreSchema:
return core_schema.no_info_after_validator_function(
cls.validate,
core_schema.str_schema(),
)
@classmethod
def validate(cls, v: str) -> 'PhoneNumber':
cleaned = ''.join(filter(str.isdigit, v))
if len(cleaned) != 10:
raise ValueError('Phone number must be 10 digits')
return cls(cleaned)
The v2 version is definitely more boilerplate, but it's also more explicit about the validation pipeline and plays better with Pydantic's Rust core.
The migration process
There's a tool called bump-pydantic that automates most of the mechanical changes:
pip install bump-pydantic
bump-pydantic .
It handles:
- Renaming @validator to @field_validator
- Adding @classmethod decorators
- Updating Config to model_config
- Renaming .dict() to .model_dump()
- Many other mechanical transformations
Our process was:
1. Run bump-pydantic
2. Run the test suite, watch it fail
3. Fix the failures one by one
4. Repeat until green
Step 3 took about a week for a medium-sized codebase.
Here's what we actually spent time on during the migration:
Day 1: Running bump-pydantic and understanding what changed. The tool did great work, but it couldn't catch everything - especially custom validators with complex logic.
Days 2-3: Fixing test failures. Most were straightforward - updating assertions that checked .dict() output, fixing mocked validators. But we found some subtle bugs where v1 was silently coercing types in ways we didn't realize.
Day 4: Dealing with third-party libraries. Some of our dependencies used Pydantic models in their APIs. We had to check if they supported v2 or find workarounds.
Day 5: Performance testing and validation error handling. We wanted to make sure the performance gains were real and that error messages were still user-friendly.
Days 6-7: Edge cases and cleanup. Custom types, complex nested models, serialization aliases - all the weird stuff that only shows up in production.
The key lesson: the mechanical changes are easy. The subtle behavioral differences and ecosystem compatibility are where you'll spend your time.
The performance gains
Was it worth it? Here's what we measured:
- Model instantiation: ~5x faster
- JSON serialization: ~4x faster
- JSON deserialization: ~3x faster
- Validation: ~2x faster
For our API, this translated to measurable improvements in response times. Not huge - we're talking milliseconds - but it adds up at scale.
Here's a more concrete example. We have an endpoint that returns a list of user objects. In v1, serializing 1000 user models took about 45ms. In v2, it takes about 11ms. That's a 4x improvement.
But the real win was in validation-heavy endpoints. We have a bulk import API that validates hundreds of complex nested models. That went from taking ~2 seconds to ~800ms. Users noticed the difference.
One surprising benefit: CPU usage dropped. Our API servers were spending a significant chunk of CPU time in Pydantic validation and serialization. After the migration, we saw a 10-15% reduction in overall CPU usage during peak load. That's real money saved on infrastructure.
The memory usage also improved, though less dramatically. The Rust core is more memory-efficient, especially when handling large numbers of model instances.
The API is actually better
Once I got past the migration pain, I realized v2's API is actually cleaner:
model_configis more explicit than magic Config classes@field_validatorand@model_validatorare clearer than the overloaded@validatormodel_dump()is more descriptive than.dict()- Type hints are better and stricter
The migration was painful, but the result is better code.
There are also some genuinely new features that make life better:
Computed fields - No more property hacks:
from pydantic import computed_field
class User(BaseModel):
first_name: str
last_name: str
@computed_field
@property
def full_name(self) -> str:
return f"{self.first_name} {self.last_name}"
user = User(first_name="Ben", last_name="Purdy")
print(user.model_dump())
# {'first_name': 'Ben', 'last_name': 'Purdy', 'full_name': 'Ben Purdy'}
Computed fields are included in serialization by default, and you can control their behavior with parameters.
Better JSON schema generation:
The JSON schema output is more accurate and includes better descriptions. If you're generating OpenAPI specs or using Pydantic for API documentation, this is a big deal.
Serialization context:
You can now pass context when serializing, which is super useful for things like user permissions:
class Post(BaseModel):
title: str
content: str
draft: bool
@model_serializer(mode='wrap', when_used='json')
def serialize_model(self, serializer, info):
data = serializer(self)
# Hide draft posts unless user has permission
if self.draft and not info.context.get('show_drafts'):
data['content'] = '[Draft content hidden]'
return data
# Normal user
post.model_dump_json()
# Admin user
post.model_dump_json(context={'show_drafts': True})
We use this pattern extensively for role-based field filtering.
Common gotchas we hit
DateTime handling changed:
from datetime import datetime
# v1 - would accept unix timestamps as integers
class Event(BaseModel):
happened_at: datetime
event = Event(happened_at=1640000000) # This worked in v1
# v2 - stricter about datetime formats
# You need to either send ISO strings or explicitly allow timestamp mode
class Event(BaseModel):
happened_at: datetime = Field(json_schema_extra={'format': 'date-time'})
# Or use a validator to handle both
from pydantic import field_validator
class Event(BaseModel):
happened_at: datetime
@field_validator('happened_at', mode='before')
@classmethod
def parse_datetime(cls, v):
if isinstance(v, int):
return datetime.fromtimestamp(v)
return v
We had several APIs that were accepting timestamps as integers, and they all broke. Had to add validators to maintain backward compatibility.
Alias handling is different:
# v1 - allow_population_by_field_name let you use both alias and field name
class User(BaseModel):
email: str = Field(alias='email_address')
class Config:
allow_population_by_field_name = True
# Both worked:
User(email='test@example.com')
User(email_address='test@example.com')
# v2 - renamed to populate_by_name
class User(BaseModel):
model_config = ConfigDict(populate_by_name=True)
email: str = Field(alias='email_address')
The functionality is the same, just renamed. But if you have a lot of aliased fields, you'll be updating config all over the place.
Validation errors have a new format:
The structure of validation errors changed slightly. If you were parsing error dictionaries in your code (like for custom API error responses), you'll need to update that logic.
# v1 error format
try:
User(email='not-an-email')
except ValidationError as e:
print(e.errors())
# [{'loc': ('email',), 'msg': 'value is not a valid email address', 'type': 'value_error.email'}]
# v2 error format - 'type' values changed
# [{'type': 'value_error', 'loc': ('email',), 'msg': 'value is not a valid email address', 'input': 'not-an-email'}]
Notice the input field is new, and the error types are more standardized. Great for debugging, but breaks code that relied on specific error type strings.
Tips if you're migrating
-
Run bump-pydantic first - it handles 80% of the work, including updating imports, renaming methods, and converting Config classes
-
Have good test coverage - you'll need it. If you don't have tests, write them before migrating. Seriously.
-
Do it in one PR - don't try to do it incrementally. Pydantic v1 and v2 can't really coexist gracefully in the same codebase.
-
Read the migration guide - seriously, it's thorough. The official docs have a comprehensive migration guide with examples for every breaking change.
-
Budget time - it took us longer than expected. Even with bump-pydantic, plan for at least a few days of fixing edge cases.
-
Check your dependencies first - before you start, verify that all your dependencies support v2. Make a list and check GitHub issues/release notes.
-
Test in a staging environment - especially if you have complex models or high-traffic APIs. We caught several subtle issues in staging that would have been bad in production.
-
Update your type stubs - if you're using mypy or pyright, make sure you update your type checking setup. Some type annotations changed.
-
Watch out for custom validators - these require the most manual work. The automated tool can't always correctly transform complex validation logic.
-
Consider using strict mode for new models - v2's strict mode is actually useful for catching bugs. For new models, consider setting
strict=Truein the config.
Was it worth it?
Absolutely. The migration was painful - I won't sugarcoat that. But the benefits are real:
- Significantly better performance, especially at scale
- Cleaner, more explicit API
- Better type safety and IDE support
- Access to new features like computed fields and serialization context
- Future-proofing (the ecosystem is moving to v2)
If you're starting a new project, just use v2. If you're maintaining v1 code, migrate when you can. The longer you wait, the more v2-only libraries you won't be able to use, and the larger your codebase will be when you finally have to migrate.
The Python ecosystem moves fast. Pydantic v2 is the future, and that future is already here. Rip off the band-aid, budget a week, and get it done. Your future self will thank you.
And hey, at least it's not as bad as migrating from Python 2 to 3. We've all been through worse.