Migrating from Pydantic v1 to v2 Without Breaking APIs

Key takeaways:

  • Pin a contract suite and an OpenAPI snapshot before touching a single model.
  • v2 does not reject numeric strings — that widely repeated warning is wrong.
  • What actually breaks: fractional floats into int fields, and ints into str fields.
  • pydantic.v1 ships inside v2, so you can migrate module by module in one process.
  • Replay real payloads through both majors and diff; do not guess at the blast radius.

This page sequences the whole upgrade described in the Pydantic V2 Migration Guide. The individual mechanical changes have their own pages; what follows is the order to do them in and how to know you have not broken anything.

The Problem This Solves

Pydantic sits on the request path of every endpoint you own. Upgrading it replaces the engine that decides which inputs are acceptable and what your responses look like — so the risk is not that the app fails to start, it is that it starts fine and quietly begins rejecting a payload some client has been sending for two years.

The standard advice is to be afraid of v2's stricter coercion. That advice is mostly repeated rather than measured, and as a result teams spend their migration budget auditing inputs that never changed while missing the two that did.

Why It Happens

v1 validation was Python: a chain of per-field functions, each free to attempt whatever conversion it liked, with int(v) semantics at the bottom. v2 moved validation into pydantic-core, a Rust engine that compiles each model once into a schema of typed validators.

That rewrite forced every implicit coercion to be written down explicitly, and the team took the opportunity to sort them into two buckets. Lax mode, the default, keeps conversions that are unambiguous and lossless — a numeric string to an int, a whole-number float to an int. Strict mode, opt-in, keeps none of them. What got dropped from lax mode is the narrow set of conversions that silently lose information: truncating 3.7 to 3, or stringifying 42 into "42" when a str was declared.

That is the actual rule, and it predicts the behaviour far better than "v2 is stricter". v2 is not broadly stricter in its default mode. It is stricter about lossy conversions specifically.

Which coercions Pydantic v2 keeps in lax mode and which it dropsLossless conversions such as a numeric string to an integer and a whole-number float to an integer are kept in v2 default lax mode. Lossy conversions such as a fractional float to an integer and an integer to a string are dropped and now raise validation errors.KEPT in v2 lax mode"42" → int3.0 → int"12.50" → floatTrue → intlossless — no information droppedDROPPED in v23.7 → intint_from_float42 → strstring_typelossy — the value would changeThe dividing line is information loss, not strictness in general.
v2's default mode is not uniformly stricter than v1. It removed exactly the conversions that would silently change the value.

Prerequisites

  • A FastAPI app on Pydantic v1 with endpoint-level test coverage.
  • Access to representative production payloads — this is the single most valuable input to the migration.
  • Pydantic 2.13.4, which bundles pydantic.v1 at version 1.10.26.

Step 1 — Pin the Contract Before Anything Moves

Write the tests that describe your API from the outside, and get them green on v1:

# 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.


def test_rejects_blank_email(client):
    assert client.post("/users/", json={"email": ""}).status_code == 422

Then snapshot the schema:

curl -s localhost:8000/openapi.json > openapi.before.json

Step 2 — Measure the Coercion Diff Yourself

Because Pydantic 2.13.4 ships the entire v1 codebase as pydantic.v1, you can declare the same model twice and run real payloads through both in one process. This is not a thought experiment about what v1 used to do — both columns below are executed:

"""Real v1-vs-v2 coercion diff, run against the pydantic.v1 shim (1.10.26) inside Pydantic 2.13.4."""
from typing import Any, Optional

from pydantic import BaseModel, ValidationError
from pydantic import v1 as pydantic_v1


class OrderV1(pydantic_v1.BaseModel):
    """Declared with the bundled v1 shim so the v1 column is executed, not remembered."""

    quantity: int
    unit_price: float
    note: Optional[str] = None


class OrderV2(BaseModel):
    quantity: int
    unit_price: float
    note: Optional[str] = None


# Inputs chosen because each one is a real migration trap.
CASES: dict[str, dict[str, Any]] = {
    "numeric-string": {"quantity": "42", "unit_price": 1.0},
    "float-with-fraction": {"quantity": 3.7, "unit_price": 1.0},
    "float-that-is-whole": {"quantity": 3.0, "unit_price": 1.0},
    "bool-as-int": {"quantity": True, "unit_price": 1.0},
    "int-as-string-float": {"quantity": 1, "unit_price": "12.50"},
    "extra-field": {"quantity": 1, "unit_price": 1.0, "unexpected": "x"},
    "none-for-optional": {"quantity": 1, "unit_price": 1.0, "note": None},
    "int-for-str-field": {"quantity": 1, "unit_price": 1.0, "note": 42},
}

Real output from _verify/output/up-pyd-v1-v2-coercion.txt:

$ GET /summary
200 OK
{
  "changed": [
    "float-with-fraction",
    "int-for-str-field"
  ],
  "unchanged": [
    "numeric-string",
    "float-that-is-whole",
    "bool-as-int",
    "int-as-string-float",
    "extra-field",
    "none-for-optional"
  ]
}

Two out of eight. That is the actual blast radius for this model, and it is worth sitting with how much smaller it is than the reputation.

The most-warned-about case is a non-event:

$ GET /diff/numeric-string
200 OK
{
  "input": {
    "quantity": "42",
    "unit_price": 1.0
  },
  "v1_1_10_26": {
    "ok": true,
    "result": {
      "quantity": 42,
      "unit_price": 1.0,
      "note": null
    }
  },
  "v2_2_13_4": {
    "ok": true,
    "result": {
      "quantity": 42,
      "unit_price": 1.0,
      "note": null
    }
  },
  "behaviour_changed": false
}

Identical. If you have read that v2 rejects "42" for an int field, that is true only under strict=True, which is not the default and not what FastAPI uses for request bodies.

Here is one that genuinely changed:

$ GET /diff/float-with-fraction
200 OK
{
  "input": {
    "quantity": 3.7,
    "unit_price": 1.0
  },
  "v1_1_10_26": {
    "ok": true,
    "result": {
      "quantity": 3,
      "unit_price": 1.0,
      "note": null
    }
  },
  "v2_2_13_4": {
    "ok": false,
    "errors": [
      {
        "loc": [
          "quantity"
        ],
        "type": "int_from_float"
      }
    ]
  },
  "behaviour_changed": true
}

v1 silently truncated 3.7 to 3 — an order for four items becoming an order for three, with no error anywhere. v2 refuses. This is a bug fix, but it is still a contract change: a client that has been sending fractional quantities has been getting a 200 and now gets a 422.

And the other:

$ GET /diff/int-for-str-field
200 OK
{
  "input": {
    "quantity": 1,
    "unit_price": 1.0,
    "note": 42
  },
  "v1_1_10_26": {
    "ok": true,
    "result": {
      "quantity": 1,
      "unit_price": 1.0,
      "note": "42"
    }
  },
  "v2_2_13_4": {
    "ok": false,
    "errors": [
      {
        "loc": [
          "note"
        ],
        "type": "string_type"
      }
    ]
  },
  "behaviour_changed": true
}

v1 stringified anything into a str field. v2 requires a string. This one bites hardest on fields like reference, external_id and postcode, where a client sending a bare number was previously fine.

Point this harness at a sample of real request bodies from your access logs and you get a precise, evidence-based list of which endpoints need a compatibility shim.

Step 3 — Run the Codemod, Then Review It

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.

The codemod handles the mechanical renames well and the semantic changes not at all. In particular it will happily rename @validator to @field_validator while leaving a values parameter in the signature, which produces the TypeError documented in migrating @validator to @field_validator, and it does not fix the class Config keys that were renamed — see model_config vs class Config, where a warned-about key is stored and then ignored.

Step 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, run the contract tests, then move on. The one hard constraint: a v1 model and a v2 model are unrelated classes, so you cannot nest one inside the other. That makes the natural migration unit a whole object graph, not a file. Start with the leaves — models nothing else embeds — and work upward.

Serialization moves separately again, since json_encoders changed behaviour rather than name: see replacing json_encoders with field_serializer. Model-level rules move as described in root_validator to model_validator.

Step 5 — Diff the Schema, and Expect Noise

curl -s localhost:8000/openapi.json > openapi.after.json
diff openapi.before.json openapi.after.json

This diff will not be empty on a correct migration, and a page that tells you to expect an empty one is setting you up to ignore the whole check. FastAPI on Pydantic v2 emits OpenAPI 3.1, where an optional field becomes anyOf: [{type: string}, {type: null}] instead of the 3.0 spelling. That is a representation change, not a contract change.

What you are looking for in the noise is a required list that gained or lost an entry, a type that changed, or a path or property that vanished. Filter the mechanical differences out with a script rather than reading it by eye, and treat what remains as the review list. Shaping that output is covered in customizing OpenAPI schema generation.

Verification

The migration is done when the contract suite passes unchanged, the filtered OpenAPI diff is empty, and the replay harness reports no unexpected behaviour changes across your sampled payloads. Keep the replay harness after the migration:

import pytest


@pytest.mark.parametrize("payload", load_sampled_production_bodies())
def test_v2_accepts_everything_v1_accepted(payload):
    v1_ok = try_validate(OrderV1, payload)
    v2_ok = try_validate(OrderV2, payload)
    assert v1_ok == v2_ok, f"behaviour changed for {payload}"

For the two cases that legitimately changed, decide per endpoint: coerce explicitly at the boundary with a mode="before" validator to preserve the old behaviour, or accept the 422 and tell the client. Both are defensible; silently changing it is not.

Trade-offs and When Not To

The module-by-module route with pydantic.v1 scaffolding is the right default for anything large, but it is not free. For the duration you have two validation engines in one process, two error types to catch, doubled import surface, and a real risk that the scaffolding imports outlive the migration and become permanent. Put a deadline and a tracking issue on them.

For a small service — a few dozen models, one team, good coverage — the phased approach is overhead. Upgrade on a branch, fix what breaks, ship it. The phased plan earns its cost when you cannot hold the whole model graph in your head or cannot land the change in one release.

Finally, resist bundling extra="forbid", strict mode, or a general schema tidy-up into this change. Each is a contract change in its own right, and the value of the OpenAPI diff comes entirely from it being small enough to read. Migrate first, tighten afterwards.

FAQ

Does Pydantic v2 reject numeric strings that v1 accepted? No. In its default lax mode v2 still coerces the string "42" to an integer, exactly as v1 did. This is the most repeated migration myth. Strings are only rejected if you opt into strict mode, so a blanket search for numeric strings in your payloads is wasted effort.

Which coercions actually changed between Pydantic v1 and v2? Two matter in practice. A float with a fractional part is no longer truncated to an int and now fails with int_from_float, and an int is no longer stringified into a str field, failing with string_type. Whole-number floats, numeric strings, booleans and extra fields all behave the same.

Can I run Pydantic v1 and v2 models in the same process? Yes. Pydantic 2.13.4 bundles the full v1 codebase as pydantic.v1, so you can import both and migrate module by module. Note that a v1 model and a v2 model are unrelated classes, so a v1 model cannot be nested inside a v2 model.

How do I prove the migration did not change my API contract? Diff the generated OpenAPI document before and after, and run a contract test suite that asserts response shapes. Expect the OpenAPI diff to be non-empty even on a correct migration, because v2 emits OpenAPI 3.1 with nullable fields as anyOf rather than 3.0 style.

What is the highest-value thing to test during the migration? Replaying real production payloads through both model versions and diffing the results. It finds the handful of inputs that genuinely change behaviour, which is a far smaller and different set than the migration guides imply.