Pydantic V2 Migration Guide for FastAPI

A Pydantic v1 to v2 migration is not a version bump with a compatibility layer over it. The validation engine was rewritten, and four distinct parts of the public API moved. The good news for anyone sizing the work is that those four surfaces are countable: you can grep for them, estimate from the counts, and know before you start roughly how large the job is.

This guide is part of Advanced Pydantic Validation and Serialization. Its job is to give you the map — what changes, which changes announce themselves and which do not, and what order to do them in. Each individual change has its own page beneath this one, and this page routes you to them rather than repeating them.

The four API surfaces that change between Pydantic v1 and v2Four rows map a v1 construct to its v2 replacement and note how each one fails if it is missed: validators become field_validator and model_validator and fail at runtime, class Config becomes model_config and fails silently, dict and json become model_dump variants and emit deprecation warnings, and json_encoders becomes field_serializer and changes the wire format.Pydantic v1Pydantic v2If you miss it@validator / @root_validator@field_validator@model_validatorimport error, or a 500class Configmodel_config = ConfigDict()silently ignored.dict() / .json().model_dump() / _json()deprecation warningsjson_encoders@field_serializerwire format changesRow two costs the most review time: nothing tells you it did not apply.
Four surfaces change, and they fail in four different ways. Sizing a migration means counting occurrences of each, then budgeting most of the review time for the surface that fails quietly.

Prerequisites

Before starting, you want three things in place. First, a test suite that asserts on request and response shapes, not just status codes — a migration that changes a field from a number to a string will pass a suite that only checks for 200. Second, a pinned dependency set, so the upgrade is one commit you can revert. Third, a sample of real request bodies captured from production traffic; the coercion questions later in this guide are much easier to answer against real payloads than against imagined ones.

You should also be comfortable with what custom validators do and how nested serialization produces a response body, because those are the two areas where a mechanical rename can compile cleanly and still change behaviour.

Core Mechanics: Two Engines, One Process

The most useful thing to understand about v2 is that it is not a rewrite of v1's Python code — it is a different engine. Validation logic lives in pydantic-core, compiled Rust, and a Python BaseModel subclass is a thin façade over a compiled core schema built once when the class is defined. That compilation step is why v2 validates quickly, and it is also why several v1 idioms have no equivalent: they assumed a Python function could be inserted anywhere in the pipeline, and now the pipeline is compiled.

Pydantic 2.13.4 ships v1 alongside it, importable as pydantic.v1. Both engines can run in the same process, which is what makes an incremental migration possible at all. The constraint is that they do not interoperate: a v2 model cannot use a v1 model as a field type, because the v2 core schema builder does not know how to describe one. The practical consequence is that your migration unit is a whole object graph, not a file. Start at the leaves — the models with no model-typed fields — and work upward, so you never leave a parent holding a child from the other engine.

The four surfaces in the diagram map onto that architecture directly. The decorators changed because validators are now positions in a compiled schema rather than functions in a chain. Configuration changed because the config is read once at class-definition time to build that schema. Serialization method names changed because serialization is now compiled too. And json_encoders was removed outright because a Python callback applied after the fact has nowhere to attach in a compiled serializer.

What strictness actually means in v2

The most persistent worry about v2 is that it will start rejecting traffic your v1 service accepted. It is worth being precise, because the default behaviour is more forgiving than its reputation. In the default lax mode, v2 still performs the conversions that preserve information, and refuses the ones that discard it. Strict mode is a separate, opt-in setting that disables cross-type coercion entirely.

Here is the difference, measured. The example declares one model twice, identical fields, differing only by model_config:

class LegacyPayload(BaseModel):
    """A model carried over from v1, unchanged. v2 revalidates it under new rules."""

    quantity: int
    sku: str
    active: bool
    ratio: float


class StrictPayload(BaseModel):
    """The same fields with strict mode on — no cross-type coercion at all."""

    model_config = ConfigDict(strict=True)

    quantity: int
    sku: str
    active: bool
    ratio: float

Running that app produces the following. This is the real transcript, not a description of one:

$ POST /legacy/  {"quantity": "21", "sku": "A-1", "active": "yes", "ratio": "1.5"}
200 OK
{
  "quantity": 21,
  "sku": "A-1",
  "active": true,
  "ratio": 1.5
}

$ POST /strict/  {"quantity": "21", "sku": "A-1", "active": "yes", "ratio": "1.5"}
422 Unprocessable Entity
{
  "detail": [
    {
      "type": "int_type",
      "loc": [
        "body",
        "quantity"
      ],
      "msg": "Input should be a valid integer",
      "input": "21"
    },
    {
      "type": "bool_type",
      "loc": [
        "body",
        "active"
      ],
      "msg": "Input should be a valid boolean",
      "input": "yes"
    },
    {
      "type": "float_type",
      "loc": [
        "body",
        "ratio"
      ],
      "msg": "Input should be a valid number",
      "input": "1.5"
    }
  ]
}

The identical body is accepted by the default model and produces three errors under strict mode. Notice also which field does not error under strict mode: sku was already a string, so it passes. Strict mode does not add rules; it removes conversions.

The migration decision this implies is a sequencing one. Turn strictness on as a separate, later change with its own rollout, not as part of the version bump — otherwise a coercion rejection and a decorator bug arrive in the same deploy and you cannot tell them apart. The detailed method for measuring how much of your real traffic changes behaviour, using captured request bodies, is in Migrating from Pydantic v1 to v2 Without Breaking APIs.

Production Implementation: Inventory, Then Rewrite

A migration goes badly when it is done model by model in reading order. It goes well when it is done surface by surface, because each surface has a different detection method and a different review cost.

Surface one: the decorators

@validator becomes @field_validator, and @root_validator becomes @model_validator. Both take an explicit mode= argument in place of v1's pre flag. Both linked guides go into the signature traps in detail, and both traps are worth knowing exist before you start: the decorators changed how arguments are bound, so a body carried over unchanged can keep a valid signature while receiving something entirely different from what it expects.

The reason to treat these as one surface and sweep them together is that bump-pydantic will rename them for you, and the renaming is the easy half. The review afterwards is the real work, and it is much faster when every renamed validator is in one diff.

Surface two: the configuration block

class Config becomes model_config = ConfigDict(...), and several keys were renamed at the same time — orm_mode to from_attributes, allow_population_by_field_name to populate_by_name, schema_extra to json_schema_extra. This is the surface that needs the most careful review, because a wrong key here does not raise. model_config vs class Config demonstrates the mechanism that makes it quiet and gives the full key map.

Surface three: serialization method names

.dict() and .json() become .model_dump() and .model_dump_json(). This surface is the cheapest, because the old names still work and announce themselves. Capturing the warning shows exactly what you are looking for:

@app.post("/deprecated-dict/")
async def call_deprecated_dict(payload: LegacyPayload) -> dict[str, Any]:
    """v1's .dict() still exists in v2 — as a shim that warns. Capture what it actually says."""
    with warnings.catch_warnings(record=True) as caught:
        warnings.simplefilter("always")
        data = payload.dict()
    return {
        "data": data,
        "warnings": [f"{w.category.__name__}: {w.message}" for w in caught],
    }

The recorded output:

$ POST /deprecated-dict/  {"quantity": 1, "sku": "A-1", "active": true, "ratio": 0.5}
200 OK
{
  "data": {
    "quantity": 1,
    "sku": "A-1",
    "active": true,
    "ratio": 0.5
  },
  "warnings": [
    "PydanticDeprecatedSince20: The `dict` method is deprecated; use `model_dump` instead. Deprecated in Pydantic V2.0 to be removed in V3.0. See Pydantic V2 Migration Guide at https://errors.pydantic.dev/2.13/migration/"
  ]
}

The warning category is the useful part. PydanticDeprecatedSince20 is emitted by every shimmed construct, which makes it an inventory tool: run your test suite with that category promoted to an error and the failures are a complete list of everything still using a v1 idiom. That is a more reliable audit than grepping, because it finds the calls your pattern missed and ignores the ones in comments.

Surface four: custom JSON encoding

json_encoders has no mechanical replacement, which is why it is its own surface and its own page. Replacing json_encoders with field_serializer covers the three v2 constructs that replace it and, importantly, the way removing the config key can change your response bytes even when the code looks equivalent.

The semantic changes no rename will catch

Two behaviours changed in ways no codemod detects, because the code stays syntactically valid. Both concern what a declaration means rather than what it is called.

In v1, annotating a field Optional[str] implied a default of None. In v2 it does not: optional and defaulted became two separate statements, and a field is required unless it has a default.

class V1Habit(BaseModel):
    """In v1 an Optional field defaulted to None. Here it has no default, so it is required."""

    name: str
    nickname: Optional[str]


class Explicit(BaseModel):
    """The v2 spelling of what v1 meant: optional AND defaulted are stated separately."""

    name: str
    nickname: str | None = None

The second change concerns aliases. In v1 a field with an alias would often still accept its own name; in v2 the alias replaces the field name as the accepted input key unless you re-admit it. The real output for both:

$ POST /v1-habit/  {"name": "Ada"}
422 Unprocessable Entity
{
  "detail": [
    {
      "type": "missing",
      "loc": [
        "body",
        "nickname"
      ],
      "msg": "Field required",
      "input": {
        "name": "Ada"
      }
    }
  ]
}

$ POST /explicit/  {"name": "Ada"}
200 OK
{
  "name": "Ada",
  "nickname": null
}

$ POST /aliased/  {"display_name": "Ada"}
422 Unprocessable Entity
{
  "detail": [
    {
      "type": "missing",
      "loc": [
        "body",
        "displayName"
      ],
      "msg": "Field required",
      "input": {
        "display_name": "Ada"
      }
    }
  ]
}

$ POST /aliased-both/  {"display_name": "Ada"}
200 OK
{
  "display_name": "Ada"
}

Both of these turn a previously-accepted request into a missing error, and both are invisible to a type checker and to a codemod. They are the strongest argument for the contract test suite in the prerequisites: a test that posts a minimal valid body and asserts 200 catches both immediately, and nothing else does.

The fix for the alias case is model_config = ConfigDict(populate_by_name=True), which re-admits the field name alongside the alias — the fourth transcript above shows it accepting the spelling the third one rejected.

Performance Notes

Performance is the usual justification for the migration, and it is a real one: validation and serialization move from interpreted Python into a compiled core, and the gain is largest exactly where it matters, on large payloads and hot endpoints. What is worth being disciplined about is not quoting a figure. The size of the improvement depends on your payload shapes and your field types, and a number measured on someone else's models tells you nothing about yours. Measure your own endpoints before and after; the direction is dependable, the magnitude is yours to find.

There is also a cost that shows up in the wrong place if you are not expecting it. Core schemas are built and compiled at class-definition time, so a codebase with a very large number of models pays that cost at import. On a service with hundreds of models this can be visible in cold-start time, which matters for serverless deployments and for test suites that re-import the app per module. It is not usually a problem, but if startup time regresses after the migration, this is where it went, not into request handling.

Once you are on v2, the practices that actually govern throughput are covered in Performance Optimization for Models — the migration is what makes those wins available, not what delivers them.

Testing Strategy

The migration test strategy has three layers, and they catch different things.

Pin the contract before you touch anything. The most valuable test you can write is one that records the exact response body for a set of representative requests, on v1, and asserts on it afterwards. Shape assertions catch the Optional and alias changes above; status-code assertions do not.

def test_response_body_is_byte_identical(client, snapshot):
    resp = client.get("/users/1")
    assert resp.status_code == 200
    assert resp.json() == snapshot   # recorded on v1, asserted on v2

Promote deprecation warnings to errors. This turns the shims into a checklist that shrinks as you work:

# pyproject.toml
[tool.pytest.ini_options]
filterwarnings = ["error::DeprecationWarning"]

Test the boundary models against real payloads. Take the captured production request bodies from the prerequisites and run them through the v2 models directly, outside FastAPI. Every rejection is either a real bug you have just found or a client you are about to break, and finding out which is much cheaper before deployment than after.

For the endpoints themselves, TestClient with dependency_overrides lets you exercise the full validation path without a database — the approach is covered in Overriding Dependencies in Tests.

Failure Modes and Diagnosis

A validator no longer runs, and nothing says so. Symptom: invalid input is accepted, tests that only check valid input pass. Diagnosis: check the decorator's mode and its argument list. A v1 body carried across can keep a signature that Python accepts while receiving different objects.

A config setting has no effect. Symptom: from_attributes behaviour missing, ORM objects failing to validate with model_type errors, or an alias setting not applying. Diagnosis: print Model.model_config and compare the keys against the v2 names. A key that was never renamed sits in the dict looking correct and is never read.

A 500 where you expected a 422. Symptom: an exception escapes validation instead of becoming a client error. Diagnosis: Pydantic converts only ValueError and AssertionError into validation errors. A TypeError raised inside a validator — commonly from subscripting something that is no longer a dict — passes straight through. Both decorator guides beneath this page cover the specific shapes this takes.

Previously-valid requests now return missing. Diagnosis: an Optional field that lost its implied default, or a field whose alias now replaces its name. Both are shown above.

The OpenAPI diff is enormous. Diagnosis: expected. Pydantic v2 emits OpenAPI 3.1, which spells nullable fields differently. Filter the mechanical differences before reviewing.

Startup got slower. Diagnosis: core schema compilation at import time, as described above. Not a request-path regression.

Choosing a Migration Order

The four surfaces do not have to be done in one commit, and there is a real decision about sequencing. This table reflects the failure mode of each, which is what should drive the order:

SurfaceDetectionReview costWhen to do it
.dict() / .json() renamesDeprecation warnings, completeVery lowFirst — mechanical, and it shrinks the warning list
Decorator renamesCodemod, then manual reviewHigh — signatures changed meaningSecond, as one sweep
Config block and keysManual; no warning for wrong keysHighest per occurrenceThird, with a config assertion test
json_encoders removalGrep; wire format may shiftMedium, but user-visibleLast, behind a byte-comparison test
Enabling strict=TrueTraffic replayA project of its ownNot during the migration at all

The ordering principle is to front-load the changes that announce themselves, so that by the time you reach the silent ones your warning log and test suite are quiet enough for a new problem to stand out.

FAQ

Which parts of a Pydantic v1 codebase actually have to change? Four surfaces change: the field-level decorator, the model-level decorator, the configuration block, and anything that customised JSON output. Everything else is either unchanged or handled by a deprecation shim. Sizing a migration means counting occurrences of those four things, not auditing every model.

Which v2 changes fail loudly and which fail silently? Renamed decorator arguments and the two-config-styles conflict raise at import, so you find them the moment a module loads. Renamed configuration keys and removed json_encoders fail silently: the key is copied into the config dict, never looked up, and your setting simply does not apply. Plan review time for the silent ones.

Do I have to migrate the whole codebase at once? No, but the unit of migration is a whole object graph rather than a single file. Pydantic 2.13.4 bundles v1 as pydantic.v1, so both engines run in one process, but a v2 model cannot contain a v1 model as a field type. Migrate from the leaf models upward so a parent is never left holding a child from the other engine.

Is Pydantic v2 stricter about types than v1? Only where a conversion would lose information. In the default lax mode v2 still coerces a numeric string to an int and a whole float to an int. It refuses conversions that discard data, such as a fractional float into an int, and it no longer turns an int into a str. Strict mode disables all cross-type coercion and is a separate decision.

Does the OpenAPI document change even if my API does not? Yes. Pydantic v2 emits OpenAPI 3.1, which spells a nullable field as anyOf with a null member rather than v1's nullable flag. A correct migration therefore produces a non-empty schema diff. Filter those mechanical differences out before reviewing so the remainder is small enough to read.