Pydantic v1 — historical, do NOT copy into v2 verbatim
Pydantic v2 field_validator vs model_validator: Choosing the Right Tool
The first time I ported a Pydantic v1 schema to v2, I did what most people do — I ran a search-and-replace from @validator to @field_validator, watched the tests pass, and shipped it. Two weeks later a teammate reordered some fields for readability, and a date-range check that had been guarding production for a year quietly stopped firing. No exception, no warning, just a validator that suddenly saw an empty info.data and shrugged. That bug is the reason I now treat the v1-to-v2 decorator split as a real design decision, not a cosmetic rename. This article is the version of that lesson I wish I'd had on day one.
1. The core split: one field versus the whole model
When the Pydantic team shipped v2 in mid-2023, the Rust rewrite stole every headline — but the change that breaks the most v1 codebases never made the front page. They also reworked the validator decorators the entire community had grown accustomed to in v1. The single, overloaded @validator got split into two more focused tools: @field_validator and @model_validator. The split looks cosmetic at first glance, but the two decorators answer fundamentally different questions, and confusing them is one of the most common ways new v2 users end up with subtly wrong validation behaviour.
The simplest way to keep them straight is to read the names literally.
@field_validator runs against a single field at a time. The function receives the value of that field (already coerced into a Python type, unless you opt into mode='before') and either returns a transformed value or raises an error. The validator has no direct knowledge of the rest of the model. If it needs sibling values, it has to ask for them through a ValidationInfo object, and even then those siblings are only available if Pydantic has already validated them in field declaration order.
@model_validator runs against the entire model instance, either before any field validation (mode='before', where the input is still a raw dict) or after every field has been validated (mode='after', where the input is the constructed model). It's the right place for any rule that involves more than one field, or any invariant that depends on the model as a whole rather than on individual columns.
If you find yourself reaching for info.data['other_field'] inside a @field_validator, that's usually a signal the rule actually belongs in a @model_validator(mode='after'). The cross-field workaround inside a field validator works, but it leaks ordering dependencies into your schema and makes refactors painful — which is exactly the bug I opened this article with.
This walkthrough assumes Pydantic 2.5 or newer, which is the line where the API stabilised for production work. The official validator reference at docs.pydantic.dev is the authoritative source for every parameter mentioned here; what follows is the explanatory companion that fills in the why.
2. A concrete example
A teammate once spent an entire afternoon hunting a phantom mismatch in an order total before realising the rule was firing in the wrong decorator — so let's build that model the right way the first time. Here's how the two decorators divide the work:
from decimal import Decimal
from pydantic import BaseModel, Field, field_validator, model_validator
class LineItem(BaseModel):
sku: str
quantity: int = Field(gt=0)
unit_price: Decimal
@field_validator('sku')
@classmethod
def sku_is_uppercase(cls, value: str) -> str:
if value != value.upper():
raise ValueError('sku must be uppercase')
return value
class Order(BaseModel):
items: list[LineItem]
declared_total: Decimal
@model_validator(mode='after')
def total_matches_lines(self) -> 'Order':
computed = sum((i.unit_price * i.quantity for i in self.items), Decimal('0'))
if computed != self.declared_total:
raise ValueError(
f'declared_total {self.declared_total} does not match computed {computed}'
)
return self
The sku_is_uppercase rule lives on a single field and needs nothing else, so a @field_validator is the natural home. The total_matches_lines rule depends on all line items and on declared_total, so it lives in a @model_validator(mode='after') where it can access the fully validated model through self. Notice that the model validator returns self rather than a dict; in mode='after' you're working with a real instance and must hand it back.
3. Validator modes: before, after, wrap, plain
Why does mode='before' mean two subtly different things depending on which decorator carries it, and why does that gap matter more than the docs let on? Understanding the matrix is essential for writing validators that compose correctly.
For @field_validator, the modes are:
mode='after'(the default): runs after Pydantic has parsed and coerced the input into the declared Python type. This is where most application-level checks live.mode='before': runs against the raw input, before any coercion. Use it when you need to massage messy input — strip whitespace, accept multiple shapes, decode a JSON string into a dict — before the type machinery sees it.mode='wrap': gives you a handler function so you can run logic both before and after the inner validation step, and even decide to short-circuit. The most powerful and the easiest to misuse.mode='plain': replaces Pydantic's built-in validation for that field entirely. You take full responsibility for producing a valid value. Reach for this only when the declared type is something likeAnyand you need a custom parser.
For @model_validator, the meaningful modes are:
mode='before': receives the raw input (typically a dict, but possibly any value) before any field validation. Useful for input shape normalisation, such as accepting both a flat and a nested representation of the same data.mode='after': receives the constructed model instance and can run cross-field invariants, derive computed fields, or raise if the overall shape is inconsistent.mode='wrap': wraps the entire model validation step. Rare in application code, but useful for instrumentation or for implementing custom serialisation/validation symmetry.
There is no model_validator(mode='plain'). If you need fully custom model construction, you usually want a @model_validator(mode='before') paired with a careful model_config.
4. Cross-field rules: the trap that catches everyone
The single biggest behavioural change from v1 to v2 is how cross-field validation works. In v1, the @validator decorator with always=True, pre=True and the values argument was the standard way to write rules that referenced sibling fields. People wrote a lot of code that looked like this:
# Pydantic v1 — historical, do NOT copy into v2 verbatim
from pydantic.v1 import BaseModel, validator
class DateRange(BaseModel):
start: int
end: int
@validator('end')
def end_after_start(cls, v, values):
if 'start' in values and v < values['start']:
raise ValueError('end must be >= start')
return v
The v2 equivalent that looks like a one-to-one port is to use @field_validator('end') with info.data['start']. It even works, as long as start is declared before end in the class body. The moment someone reorders the fields, or adds a new validator on start that raises, the end validator sees an empty info.data and silently skips the cross-field check. This is the exact bug I described in the opener — not a theoretical one — and the official migration guide calls it out specifically because enough teams have been bitten.
The correct v2 pattern is to lift the cross-field rule into a @model_validator(mode='after'). By the time an after model validator runs, every field has been validated and is reachable through self, regardless of declaration order. Your invariant becomes order-independent and the intent is clearer to anyone reading the schema later.
5. When @field_validator really is the right answer
It would be wrong to conclude that you should always prefer model validators. Field validators have several genuine advantages and should be the default for single-field rules.
First, they participate in Pydantic's field-level error reporting. When a @field_validator raises, the resulting ValidationError pinpoints the offending field path, which is invaluable when surfacing errors to API clients or filling out form UIs. A model validator that touches several fields can only point at the model root unless you build the error location manually.
Second, they compose naturally with Field(...) constraints. A Field(gt=0) plus a @field_validator for the same field run in a predictable order, and the field-level error path stays intact. Mixing constraint-style validation with a model-level rule complicates the reporting story.
Third, they're easier to unit test in isolation. You can call the underlying classmethod with a single value and assert on the result without constructing the whole model. Model validators, especially mode='after', require you to build a valid model first, which means you have to satisfy every other invariant just to test one.
Finally, @field_validator(mode='before') is the right tool for input coercion that's specific to one field — for example, accepting a string like '2026-05-27' and a datetime object interchangeably, or stripping currency symbols from a numeric input. Doing this in a model validator pollutes the cross-field layer with parsing concerns.
6. Performance: it matters less than you think
A recurring question in the pydantic GitHub issue tracker is whether one decorator is faster than the other. The honest answer is that the difference is almost always lost in the noise compared to the rest of your validation graph. Pydantic v2's core is implemented in Rust via the pydantic-core crate, and the decorator dispatch is a thin Python wrapper either way. If you're validating a hundred thousand records and shaving microseconds matters, profile first. In almost every realistic application, the bottleneck is I/O or the upstream parser (JSON, MsgPack, form decoding), not the choice of decorator.
What does matter for performance is avoiding accidental work. A @model_validator(mode='before') that runs an expensive normalisation on every input, only to be followed by per-field validators that re-do half the work, is a real cost. So is a @field_validator(mode='wrap') that calls the inner handler twice. Keep each validator focused, return early, and let Pydantic's built-in coercion handle the type plumbing.
7. A practical decision checklist
When you sit down to write a new validator, work through these questions in order:
- Does the rule depend on only one field? If yes, use
@field_validator. Stop. - Does the rule depend on more than one field? Use
@model_validator(mode='after'). - Are you reshaping the input before any field validation runs, accepting multiple input shapes, or doing top-level coercion? Use
@model_validator(mode='before'). - Are you doing input coercion on a single field that the built-in type system can't express? Use
@field_validator(mode='before'). - Do you need to wrap, instrument, or replace Pydantic's own validation step? Use a
mode='wrap'ormode='plain'variant — but document why.
Follow that order and the vast majority of your validators will end up as either default-mode @field_validator or @model_validator(mode='after'), which is exactly the shape the Pydantic v2 design encourages. The exotic modes exist for the rare cases that genuinely need them, not as everyday tools.
8. Migration tips for teams coming from v1
If you're porting an existing v1 codebase, resist the temptation to do a one-line search-and-replace from @validator to @field_validator. I've already confessed how that ends. Instead, audit each existing validator for one of three patterns:
- Pure single-field rule, no
valuesaccess: straight conversion to@field_validator. Add the@classmethoddecorator explicitly (v2 makes this required). - Single-field rule that reads
values: re-home as@model_validator(mode='after'). Read sibling fields throughself. This is the change most likely to fix latent ordering bugs. pre=True, always=Trueon a field that may be missing: convert to@model_validator(mode='before')and decide explicitly how to handle the missing-key case. The v1always=Trueflag doesn't have a direct v2 equivalent because the v2 model is constructed from a fully-typed dict pipeline.
While you're at it, consider whether some of your validators have outgrown the schema entirely. Validation logic that fetches data from a database, calls an external API, or depends on application state usually doesn't belong on the Pydantic model at all. Move it to a service-layer function and keep the model as a pure data contract. That separation pays off the next time you need to deserialise the same shape in a different context, such as a worker pulling messages off a queue.
9. Closing thoughts
The split between @field_validator and @model_validator is one of the cleanest improvements in Pydantic v2. Once you internalise that field validators are for single-field rules and model validators are for cross-field invariants, most of the confusion evaporates. The modes (before, after, wrap, plain) give you precise control over when in the pipeline a rule runs, but you can build a robust schema using only the defaults plus a handful of mode='before' cases for input normalisation.
The biggest win, both for correctness and for readability, is to stop reaching for cross-field tricks inside field validators. Lift those rules to @model_validator(mode='after') and your schemas become order-independent, easier to test, and friendlier to the next person who has to refactor them. That alone justifies the v2 redesign, even before you count the speedups.