Validator Modes

before, after, plain and wrap - what runs when, relative to coercion, and why the choice changes what you can do.

Overview

The pipeline

Validating one field is a sequence, and every mode is a slot in it:

  1. before validators run on the raw input, exactly as it arrived.
  2. Coercion converts the value to the annotated type.
  3. Constraints from Field are checked.
  4. after validators run on the final, converted value.

plain and wrap are different: they replace or surround the whole thing rather than sitting inside it.

Almost every confusion about validators dissolves once this sequence is clear, because the question "why did my validator not run?" nearly always has the answer "it was in after mode and the value failed at step 2".

Worth knowing

before sees the raw input, unconverted. Use it to fix the shape of a value.
after is the default and sees the coerced value, already the annotated type. Use it to check or normalise the value.
plain replaces validation entirely — no coercion runs, and whatever it returns is the field.
wrap receives the value and a handler, so it can run normal validation and catch the failure.
The commonest mistake is putting shape-fixing logic in after mode, where the bad shape has already failed validation and the code never runs.
Both spellings exist: Annotated[T, BeforeValidator(f)] for a reusable type, @field_validator("x", mode="before") for one model.

Validator Modes: before, after, plain and wrap

What runs when, relative to coercion, and why the choice decides what a validator can do.

Seeing the pipeline

One field, one validator of each kind, printing what it received. The order and the types tell you everything.

example_01.pyPydantic
Output

before is for shape, after is for value

Choosing the wrong mode is the usual reason a validator never runs: in after mode a bad shape has already failed.

example_02.pyPydantic
Output

plain replaces validation entirely

A plain validator takes over: no coercion happens at all, and whatever it returns is the field.

example_03.pyPydantic
Output

wrap sees both sides

A wrap validator receives the value and the handler that would normally validate it, so it can catch a failure and substitute something.

example_04.pyPydantic
Output

Order among several

Validators of the same kind run in the order written. Before-validators run outside-in, after-validators inside-out.

example_05.pyPydantic
Output

The same modes on a decorator

field_validator takes the same mode argument. Annotated makes it reusable; the decorator keeps it local.

example_06.pyPydantic
Output

after: the default

An after validator receives the value already converted. n: int given "12" reaches an after validator as the integer 12.

That is what you want for nearly everything. Checking membership of a set, comparing against a bound that a constraint cannot express, normalising a string, deduplicating a list — all of these are simpler when the value is known to be the right type, because you can operate on it directly with no defensive isinstance.

It is also safer. An after validator cannot receive an arbitrary object, so it cannot fail in surprising ways on input it never anticipated.

before: fixing the shape

A before validator sees the raw input. Nothing has been checked, so the value could be anything.

Its purpose is repairing the *shape* of data before normal validation gets to it. The canonical case is a field that should be a list, from a caller that sends a comma-separated string:

def split_csv(v):
    return [p.strip() for p in v.split(",")] if isinstance(v, str) else v

In after mode this code would never run. A string is not a list, so validation fails at step 2 and step 4 is never reached. This is the single most common validator mistake, and the symptom — "my validator is not being called" — sounds like a bug in Pydantic rather than a mode choice.

Two rules for before validators, both consequences of receiving unchecked input. Guard with isinstance rather than assuming a type. And return anything you do not handle unchanged, so normal validation still runs on it.

Keep them small. Complex logic operating on unvalidated input is hard to reason about, and errors raised there lack the context that makes Pydantic's messages useful.

plain: taking over

A plain validator replaces validation for that field. No coercion runs, no constraints are checked, and whatever it returns becomes the value — unvalidated.

minutes: Annotated[int, PlainValidator(parse_duration)]

The annotation still says int, and that now serves as documentation and as the schema type rather than as something enforced. Your function is entirely responsible for producing an integer.

This is the right tool for a genuinely custom input format: durations like "1h30m", a coordinate string, a domain-specific identifier. Trying to express those with before validators plus normal coercion is more convoluted than simply owning the parse.

The cost is that you have taken on the whole job, including producing sensible errors. Raise ValueError for bad input rather than returning something wrong, or you have built a field that silently accepts nonsense.

wrap: around the outside

A wrap validator receives the value *and* a handler that performs the normal validation. That lets it act before, after, and instead of:

def default_on_failure(v, handler):
    try:
        return handler(v)
    except ValidationError:
        return 0

The main uses are supplying a fallback instead of failing, adding context to an error before re-raising, and short-circuiting expensive validation for a known-good sentinel.

Use it sparingly, and be honest about the fallback case. Swallowing a ValidationError and substituting a default is a decision to lose information: the caller sent something wrong and will never know. That is occasionally right — a metrics field where a bad value should not fail the whole request — and frequently a way to hide a bug for months.

Order

Several validators of the same kind run in the order written. Read the annotation left to right and you are reading the pipeline top to bottom.

Mixed kinds follow the sequence: all before validators, then coercion and constraints, then all after validators.

That makes a normalise-then-check type read naturally:

Slug = Annotated[str, BeforeValidator(to_slug), Field(pattern=r"^[a-z0-9_]+$")]

Normalise, then coerce, then check. Swap the order of the metadata and the intent changes.

Two spellings

Everything here exists in both forms.

Annotated[T, BeforeValidator(f)] makes the rule part of a type, so it is reusable across models. This is the better default for anything a second model will ever need.

@field_validator("x", mode="before") attaches the rule to one model. Better when the logic is genuinely specific to that model, or when it needs info.data to see other fields — which the Annotated form does not provide.

model_validator also takes mode, with before receiving the whole raw payload and after receiving the finished model. Same vocabulary, model-wide scope.

Choosing

Ask what the value looks like when your logic needs to see it.

Already the right type — after. This covers most cases.

Still in its raw form, wrong shape &mdash> before.

A format Pydantic has no idea about — plain.

Need to catch or replace a failure — wrap.

And when a validator does not seem to run, check the mode first. It is the answer far more often than anything else.

A worked example

A duration field accepting 90, "90" and "1h30m", rejecting nonsense, and constrained to a sensible range.

plain is the honest choice: the input format is not something coercion can be expected to handle, and taking over the parse is clearer than a chain of before-validators trying to massage "1h30m" into something int() will accept.

Since a plain validator skips constraints, the range check moves into the function or into a separate after validator. That is the trade: full control over parsing, full responsibility for everything downstream of it.

Modes on model validators

The same vocabulary applies at model level, with different scope.

model_validator(mode="before") receives the raw input for the whole payload — usually a dict, but it can be anything. It is a classmethod, and it returns the data to be validated. Use it to translate a legacy shape or fill in a derived key before field validation runs.

model_validator(mode="after") receives the finished model as self and returns it. This is where cross-field rules belong.

There is a mode="wrap" at model level too, receiving the payload and a handler, which can catch a whole-model failure and substitute something. It is rare and powerful; the same caution about swallowing errors applies.

What each mode can and cannot do

Worth a table in your head.

A before validator can change the shape, cannot rely on the type, and its errors carry less context because nothing has been located yet.

An after validator can rely on the type, cannot repair a shape that already failed, and gets good error locations for free.

A plain validator controls everything and inherits nothing — no coercion, no constraints, so anything you want enforced you must enforce yourself.

A wrap validator can do all of the above and is the only one that can observe a failure and decide what to do about it.

Debugging when a validator misbehaves

Three questions, in order, that resolve nearly every case.

Is it running at all? Put a print at the top. If nothing appears, the mode is wrong — almost always an after validator whose input failed coercion first.

What type is it receiving? Print type(v). A before validator gets raw input and an after validator gets the coerced value; assuming the wrong one is the second most common mistake.

Is it returning? A validator with no return sets the field to None, silently. If a field becomes None after you added a validator, that is the cause.

Constraints still run in the middle

A detail that is easy to forget: Field constraints sit between the two validator slots.

So Annotated[int, BeforeValidator(f), Field(gt=0), AfterValidator(g)] runs f on raw input, coerces, checks > 0, then runs g. An after validator never sees a value that failed a constraint, because the constraint raised first.

That is usually convenient — the after validator can assume the bounds hold — and occasionally the reason a validator meant to *fix* an out-of-range value never runs. Clamping belongs in before, or in the constraint's absence.

Performance

Validators are Python, and Python is the slow part of an otherwise Rust pipeline.

For a model built a few times per request this is irrelevant. For validating a large collection it is the dominant cost, because each item's validators are a round trip out of the core and back.

If profiling points at validation on a hot path, the questions are: can this rule be a constraint instead, since constraints run in Rust; and is the validator doing work that could be done once outside rather than per item. A validator that rebuilds a set of permitted values on every call is a common and easily fixed version of the second.

Summary

Four slots around one pipeline: before, coerce, constrain, after. plain replaces the pipeline; wrap surrounds it.

Choose by asking what the value looks like when your logic needs to see it. When a validator does not run, check the mode before anything else. And remember that constraints sit between the two ordinary slots, so an after validator only ever sees values that already passed them.

The mental model

One sentence holds most of it: before sees what arrived, after sees what it became.

Everything else follows. A validator repairing a shape must run before, because after the shape has already failed. A validator checking meaning should run after, because it can then rely on the type. Constraints sit between them, so an after validator never sees a value that broke one.

plain and wrap step outside that sequence — one replacing it, the other surrounding it — and both are for cases where the standard pipeline is not what you want at all.

Keep the sentence and the rest is derivable.

Mistakes people make

Shape-fixing logic in after mode. The single most common, and it presents as "my validator never runs". The value failed coercion at step two and step four was never reached.

Assuming the type in a before validator. It receives raw input, which can be anything at all. Guard with isinstance and pass through unchanged whatever you do not handle, so the normal path still runs.

Forgetting to return. Silent, and it sets the field to None. If a field mysteriously becomes None after a validator is added, this is why.

Expecting constraints to run after a plain validator. plain replaces the pipeline entirely: no coercion and no constraints. Anything you want enforced is now yours to enforce.

Swallowing errors in a wrap validator. Catching a ValidationError and substituting a default is a decision to lose information — the caller sent something wrong and will never be told. Occasionally correct, frequently a bug hidden for months.

Rebuilding data inside a validator on every call. A validator that constructs a set of permitted values each time it runs does that work once per item validated. Hoist it out; on a large collection it is the dominant cost.

A closing note

Nearly every question about validators reduces to a question about position.

Not "how do I write this rule" but "where in the sequence does it need to sit". Once that is settled the code is usually short and obvious, and when a validator behaves strangely the position is almost always what is wrong.

Before, coerce, constrain, after. Four slots, one order, and everything else follows from knowing which one you are in.

Check yourself

0 of 4

Answer without scrolling back up.

  1. Your validator turns a CSV string into a list, but it never runs. Why?

  2. What does a `plain` validator skip?

  3. What does a `wrap` validator receive besides the value?

  4. In `Annotated[str, BeforeValidator(to_slug), Field(pattern=p)]`, what is the order?

Cheat sheet

Validator Modes

Almost every confusion about validators dissolves once this sequence is clear, because the question "why did my validator not run?" nearly always has the answer "it was in after mode and the value failed at step 2".

PYDANTIC · vizlearn.in/pydantic/validator_modes.html

About the author

Ashish Jangra builds and maintains VizLearn. Every module here is written and the visualisation behind it hand-built, so the numbers in a readout come from the same code that draws the picture. Corrections are genuinely welcome and get priority over everything else — if a page states something wrong, or an animation misrepresents what the algorithm does, get in touch.