Migrating from Pydantic v1 to v2 Without Breaking APIs
Key takeaways:
- Pin a contract test suite before touching any model, so you can prove the API is unchanged.
- Run the
bump-pydanticcodemod for the mechanical renames, then review every change. - Migrate one module at a time using the v1 compatibility namespace for the rest.
- Audit coercion by replaying real payloads, since v2 is stricter than v1.
- Roll out only when the contract suite and an OpenAPI diff both pass.
This guide executes the strategy from the Pydantic V2 Migration Guide. Read that page for the full list of API changes.
The Problem This Solves
A Pydantic upgrade rewrites the engine that validates and serializes every request and response, so a careless migration can silently change your API contract — rejecting inputs that used to work or altering output shapes. The phased approach below keeps external behavior identical while you modernize the internals.
Prerequisites
- A FastAPI app on Pydantic v1 with reasonable test coverage of endpoints.
- The ability to run the app against representative production payloads.
Step-by-Step Implementation
1. Pin the contract first
# tests/test_contract.py — written and passing BEFORE the migration.
def test_user_response_shape(client):
body = client.get("/users/1").json()
assert set(body) == {"id", "email", "created_at"} # The external contract.
2. Snapshot the OpenAPI document
# Capture the current schema so you can diff after migrating.
curl -s localhost:8000/openapi.json > openapi.before.json
3. Run the codemod and review
pip install "pydantic>=2" bump-pydantic
bump-pydantic app/ # Renames validators, Config, .dict()/.json(), Field args.
git diff # Review every change — the codemod is not infallible.
4. Convert module by module
# Temporarily import from the compatibility namespace for not-yet-migrated modules.
from pydantic.v1 import BaseModel as BaseModelV1 # Scaffolding — remove later.
Migrate one domain's models fully to v2, run the contract tests, then move to the next. Never leave a module half-converted.
5. Audit coercion against real input
import pytest
from pydantic import ValidationError
def test_numeric_string_now_rejected():
# v1 accepted "42"; v2 is strict. Decide: coerce explicitly or reject.
with pytest.raises(ValidationError):
CreateOrder.model_validate({"quantity": "42"})
6. Diff and roll out
curl -s localhost:8000/openapi.json > openapi.after.json
diff openapi.before.json openapi.after.json && echo "contract unchanged"
Edge Cases and Gotchas
- Custom JSON encoders. v1
Config.json_encodersmigrates to field serializers ormodel_serializer; the codemod does not always handle these. - Mutable defaults. v2 handles defaults differently; review fields with mutable defaults such as lists and dicts.
- ORM mode.
orm_mode = Truebecomesmodel_config = ConfigDict(from_attributes=True). - Settings.
BaseSettingsmoved to the separatepydantic-settingspackage, as covered in Managing Environment Variables with Pydantic Settings.
Verification
The migration is done when, against the same inputs, the contract test suite passes and the OpenAPI diff is empty. At that point external consumers are unaffected and you can delete the v1 compatibility imports.
Related Reading
- Up to the topic: Pydantic V2 Migration Guide.
- Related patterns: Custom Validators and Field Constraints for the new validator API, and Performance Optimization for Models to capture v2's speed.