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-pydantic codemod 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_encoders migrates to field serializers or model_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 = True becomes model_config = ConfigDict(from_attributes=True).
  • Settings. BaseSettings moved to the separate pydantic-settings package, 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.