Migrate @validator to @field_validator in Pydantic v2

Key takeaways:

  • @classmethod is not required at runtime — the common advice is wrong on Pydantic 2.13.4.
  • A v1 values third argument becomes a ValidationInfo, and subscripting it raises TypeError.
  • That TypeError is not converted to a 422; it surfaces as a 500.
  • pre=True raises TypeError at import, so it is the one mistake that fails loudly.
  • The v1 @validator shim still runs, but emits PydanticDeprecatedSince20 on every model.

This is the field-level half of the Pydantic V2 Migration Guide; the model-level half is migrating @root_validator to @model_validator. It builds on custom validators and field constraints.

The Problem This Solves

@validator is usually the most-used Pydantic feature in an application, so this decorator accounts for most of the diff in a v1-to-v2 upgrade. The renames are trivial. What is not trivial is that the four possible mistakes fail in four completely different ways — one at import, one at validation time as a 500, one as a deprecation warning you can ignore for years, and one not at all.

Knowing which is which tells you what your test suite can actually catch.

Why It Happens

In v1, @validator had a flexible calling convention. It inspected the decorated function and passed whichever of values, config, and field the signature asked for by name. That is why v1 validators read like def check(cls, v, values) — the name values was meaningful.

v2 replaced that with a fixed, positional convention. A field validator takes either one argument or two: the value, and optionally a ValidationInfo. Pydantic counts the parameters and binds accordingly. It does not look at their names.

So a v1 validator carried across unchanged still has a valid arity — two parameters after cls becomes one value plus one info object — and Pydantic happily binds it. Your parameter is called values, and it contains a ValidationInfo. The class definition succeeds. Everything looks migrated. The failure waits until something actually validates and the code does values["start"].

How Pydantic v2 binds field validator arguments by position rather than by namePydantic v1 inspected parameter names and passed a values dict. Pydantic v2 counts parameters and binds the second one to a ValidationInfo object whatever it is named, so a v1 validator subscripting values raises a TypeError.Pydantic v1 — bound by NAMEdef check(cls, v, values)values = dict of fields so farPydantic v2 — bound by POSITIONdef check(cls, v, values)values = ValidationInfo objectunchangedThe arity still matches, so the class defines cleanly.Nothing warns. Nothing fails yet.values["start"] raises TypeError when it runsnot converted to a 422 — it becomes a 500
The signature that used to mean "give me the other fields" now means "give me a ValidationInfo". Arity matches, so nothing complains until it runs.

Prerequisites

  • Pydantic 2.13.4 and FastAPI 0.139.2 — the versions every transcript below was produced on.
  • A contract suite pinned before you start, per migrating without breaking APIs.

The Four Failure Modes, Executed

Each case below defines a model at runtime with warnings recorded, then validates a payload through it:

"""What v1 @validator signatures actually do when moved to v2 @field_validator."""
NAMESPACE_SOURCE = {
    # 1. v1's decorator still exists in v2 as a deprecated shim. What does using it cost?
    "v1-validator-shim": '''
from pydantic import validator

class M(BaseModel):
    name: str

    @validator("name")
    def strip(cls, v):
        return v.strip()
''',
    # 2. The straight rename, WITHOUT adding @classmethod. Error, or does it work?
    "no-classmethod": '''
class M(BaseModel):
    name: str

    @field_validator("name")
    def strip(cls, v):
        return v.strip()
''',
    # 3. The v1 three-argument signature: (cls, v, values).
    "values-arg": '''
class M(BaseModel):
    start: int
    end: int

    @field_validator("end")
    @classmethod
    def after_start(cls, v, values):
        if v <= values["start"]:
            raise ValueError("end must be after start")
        return v
''',
    # 4. v1's `pre=True` keyword argument passed to field_validator.
    "v1-kwargs": '''
class M(BaseModel):
    amount: int

    @field_validator("amount", pre=True)
    @classmethod
    def coerce(cls, v):
        return v
''',
}

Real output from _verify/output/up-pyd-field-validator-signature.txt:

$ GET /case/v1-validator-shim
200 OK
{
  "outcome": "class defined",
  "validated": {
    "name": "ada"
  },
  "warnings": [
    "PydanticDeprecatedSince20: Pydantic V1 style `@validator` validators are deprecated. You should migrate to Pydantic V2 style `@field_validator` validators, see the migration guide for more details. Deprecated in Pydantic V2.0 to be removed in V3.0. See Pydantic V2 Migration Guide at https://errors.pydantic.dev/2.13/migration/"
  ]
}

$ GET /case/no-classmethod
200 OK
{
  "outcome": "class defined",
  "validated": {
    "name": "ada"
  },
  "warnings": []
}

$ GET /case/values-arg
200 OK
{
  "outcome": "class defined",
  "exception_type": "TypeError",
  "message": "'pydantic_core._pydantic_core.ValidationInfo' object is not subscriptable",
  "warnings": []
}

$ GET /case/v1-kwargs
200 OK
{
  "outcome": "raised at class definition",
  "exception_type": "TypeError",
  "message": "field_validator() got an unexpected keyword argument 'pre'"
}

Four cases, four different outcomes, and only one of them is what the folklore predicts.

The @classmethod claim is false. no-classmethod produced a working model with no warnings at all. It is widely repeated that omitting @classmethod raises at class definition time; on 2.13.4 it does not. And the first argument is still the class:

$ GET /case/cls-identity
200 OK
{
  "outcome": "class defined",
  "first_arg_is_the_model_class": true,
  "first_arg_repr": "M",
  "warnings": []
}

Pydantic applies the classmethod binding itself. You should still write @classmethod, because without it mypy and your IDE read the function as an instance method and will flag cls as a mis-named self — but that is a tooling argument, not a runtime one, and treating it as a runtime requirement means you go looking for an exception that never comes.

The values case is the one that hurts. It defines cleanly and fails only when a request arrives. And because the failure is a TypeError rather than a ValueError, Pydantic does not convert it into a validation error — it escapes model construction entirely and FastAPI returns a 500, not a 422. A codebase with two hundred @validator methods will have a handful of these lurking on rarely-exercised branches.

pre=True is the good failure. It raises at import, so it cannot reach production.

The Migration

1. Rename the decorator, keep the body

# Pydantic v1 — the idiom being replaced. Runs on v2 only through the deprecated shim.
class V1(BaseModel):
    name: str

    @validator("name")
    def strip(cls, v):
        return v.strip()
class Signup(BaseModel):
    name: str

    @field_validator("name")
    @classmethod
    def strip(cls, v: str) -> str:
        stripped = v.strip()
        if not stripped:
            raise ValueError("name must not be blank")
        return stripped

2. Translate pre=True to mode="before"

@field_validator("amount", mode="before")   # was @validator("amount", pre=True)
@classmethod
def coerce(cls, v: object) -> object:
    return int(v) if isinstance(v, str) else v

The default is mode="after", which runs once the value has been coerced to the declared type. The full ordering model, including wrap, is in before, after and wrap validators.

3. Replace values with info.data — or move the rule

The literal translation takes a third parameter and reads info.data:

@field_validator("end")
@classmethod
def after_start(cls, v, info):
    if "start" in info.data and v <= info.data["start"]:
        raise ValueError("end must be after start")
    return v
$ GET /case/info-arg
200 OK
{
  "outcome": "class defined",
  "validation_errors": [
    {
      "type": "value_error",
      "loc": [
        "end"
      ],
      "msg": "Value error, end must be after start",
      "input": 1,
      "ctx": {
        "error": "end must be after start"
      },
      "url": "https://errors.pydantic.dev/2.13/v/value_error"
    }
  ],
  "warnings": []
}

Note the if "start" in info.data guard. info.data holds only the fields validated before this one, in declaration order, and a field that failed its own validation is absent. That is the same defensive values.get(...) dance v1 forced on you, and it is why the better migration for most cross-field rules is a model_validator(mode="after"), which runs on a fully constructed model. See cross-field validation patterns.

4. always=True has no direct replacement

v2 does not validate defaults at all unless asked. Where you relied on always=True, set validate_default=True on the field or in model config.

The 422, Before and After

The migrated validator raising ValueError produces the shape v1 consumers expect. Through a real FastAPI request body:

$ POST /signup  {"name": "  ada  "}
200 OK
{
  "name": "ada"
}

$ POST /signup  {"name": "   "}
422 Unprocessable Entity
{
  "detail": [
    {
      "type": "value_error",
      "loc": [
        "body",
        "name"
      ],
      "msg": "Value error, name must not be blank",
      "input": "   ",
      "ctx": {
        "error": {}
      }
    }
  ]
}

Two details matter for consumers. The msg is prefixed with Value error, — v2 prepends the error class, so a client asserting on an exact message string breaks even though your validator's text is unchanged. And ctx.error serializes to {}, because FastAPI JSON-encodes the original exception object and it has no useful representation. If either matters to your clients, normalise the envelope with customising validation error responses.

Verification

The compiler cannot help here, so lean on grep and on warnings-as-errors:

import warnings

import pytest
from pydantic import ValidationError


def test_no_v1_validators_remain():
    with warnings.catch_warnings():
        warnings.simplefilter("error", DeprecationWarning)
        import app.schemas  # noqa: F401 — the v1 shim warns on import


def test_blank_name_is_a_422_not_a_500():
    with pytest.raises(ValidationError) as exc_info:
        Signup(name="   ")
    assert exc_info.value.errors()[0]["type"] == "value_error"

The second test is the shape of the check that catches the values bug. Asserting ValidationError specifically — rather than pytest.raises(Exception) — is what distinguishes a working validator from one that is about to return a 500. Pair it with a grep for values[ inside @field_validator bodies; every hit is a latent server error.

Trade-offs and When Not To

The deprecated @validator shim genuinely works, and on a large codebase there is a case for leaving it in place for a release while you migrate other things. The cost is a deprecation warning per validator, which drowns your test output and trains the team to ignore warnings — and it disappears entirely in V3, converting a warning into an import error at the worst possible moment. If you take the delay, run CI with -W error::DeprecationWarning on a single canary module so the count is visible and shrinking.

There is also a limit to how much belongs in a field validator at all. A validator is the right home for rules about this value — format, range, normalisation. Once a rule needs another field, info.data is fighting you with ordering constraints, and a model validator is clearer. Once a rule needs I/O it does not belong in the model at all, because Pydantic validators cannot await; see Pydantic v2 async custom validator. And once the same rule appears on a fourth model, it wants to be a type — see creating reusable custom validators.

FAQ

Does @field_validator really require @classmethod? No. On Pydantic 2.13.4 a field validator without @classmethod is accepted and works, and the first argument is still bound to the model class. The decorator is recommended because static type checkers otherwise read the function as an instance method, but omitting it is not a runtime error.

What error does a v1 values argument produce in v2?TypeError: 'pydantic_core._pydantic_core.ValidationInfo' object is not subscriptable. The third parameter is bound positionally to a ValidationInfo regardless of what you name it, so a v1 validator that subscripts values fails when it runs, not when the class is defined.

Why is the values TypeError dangerous during a migration? Because Pydantic only converts ValueError and AssertionError into validation errors. A TypeError propagates out of model construction and becomes a 500 rather than a 422, so a half-migrated validator fails as a server error on whichever code path happens to reach it.

What replaces pre=True and always=True?pre=True becomes mode="before". Passing pre to field_validator raises TypeError at import, so this one fails loudly. always=True has no direct equivalent; defaults are not validated unless you set validate_default=True on the field or in model config.

How do I read another field inside a v2 field validator? Take a third parameter and read info.data, which holds the fields validated so far. It is order-dependent and only contains fields declared before the current one, so for anything non-trivial use a model_validator with mode="after" instead.