before, after, plain and wrap - what runs when, relative to coercion, and why the choice changes what you can do.
Overview
The pipeline
Every validator in Pydantic runs at a particular point in the pipeline. Some run after the built-in type check. Some run before it. Some replace it entirely. A few wrap around it and decide what to do with the result. The point where a validator runs is called its mode.
Choosing the right mode matters. A validator that runs too late sees a value that has already been converted, so it cannot fix the raw input. A validator that runs too early sees data that has not been checked, so it must defend itself against anything. Most of the confusing validator bugs come from the wrong mode.
This article lays out the pipeline first, so you can see where each mode sits. It covers after, which is the default and the safest. It covers before, which is for fixing the shape of the input. It covers plain, which takes over the check completely, and wrap, which surrounds it. For each one you will see what the validator receives and what it is responsible for. Once you know the four, you can pick the right one for a rule by thinking about when it should run.
The names are easy to remember once the pipeline is clear. Think of the check as a line of stations. Each mode is a different position on that line. You will leave the article knowing which station to stand at for each kind of rule, which is the whole skill.
Pick the mode by asking one question, which is when the rule should run.
checking one field is a sequence, and each mode is a slot in it:
before checkers run on the raw input, just as it arrived.
Coercion converts the value to the annotated type.
Constraints from Field are checked.
after checkers run on the final, converted value.
plain and wrap are other: they replace or surround the whole thing rather than sitting inside it.
Almost each confusion about checkers dissolves once this sequence is clear. The question "why did my checker 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, scaled to coercion. Why the choice decides what a checker 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
from typing import Annotated
from pydantic import BaseModel, BeforeValidator, AfterValidator
def before(v):
print(" before : %-8r (%s)" % (v, type(v).__name__))
return v
def after(v):
print(" after : %-8r (%s)" % (v, type(v).__name__))
return v
class M(BaseModel):
n: Annotated[int, BeforeValidator(before), AfterValidator(after)]
print('validating "12":')
m = M(n="12")
print(" result :", repr(m.n))
print()
print("before saw the string; coercion ran; after saw the int.")
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
from typing import Annotated, List
from pydantic import BaseModel, BeforeValidator, AfterValidator, ValidationError
def split_csv(v):
return [p.strip() for p in v.split(",")] if isinstance(v, str) else v
def lowercase(v: List[str]) -> List[str]:
return [t.lower() for t in v]
class Right(BaseModel):
tags: Annotated[List[str], BeforeValidator(split_csv), AfterValidator(lowercase)]
class Wrong(BaseModel):
# split_csv in AFTER mode never sees the string - validation failed first
tags: Annotated[List[str], AfterValidator(split_csv)]
print("right:", Right(tags="Maths, Vectors").tags)
try:
Wrong(tags="Maths, Vectors")
except ValidationError as e:
print("wrong:", e.errors()[0]["type"], "- the string never reached the validator")
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
from typing import Annotated, Any
from pydantic import BaseModel, PlainValidator
def parse_duration(v: Any) -> int:
"Accept 90, '90', '1h30m' - and decide the result ourselves."
if isinstance(v, int):
return v
s = str(v).strip()
if s.isdigit():
return int(s)
total, num = 0, ""
for ch in s:
if ch.isdigit():
num += ch
elif ch == "h":
total += int(num or 0) * 60; num = ""
elif ch == "m":
total += int(num or 0); num = ""
return total + int(num or 0)
class Module(BaseModel):
minutes: Annotated[int, PlainValidator(parse_duration)]
for value in [90, "90", "1h30m", "2h", "45m"]:
print("%-8r -> %d" % (value, Module(minutes=value).minutes))
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
from typing import Annotated, Any
from pydantic import BaseModel, WrapValidator, ValidationError, ValidatorFunctionWrapHandler
def default_on_failure(v: Any, handler: ValidatorFunctionWrapHandler) -> int:
try:
return handler(v) # run normal validation
except ValidationError:
return 0 # ...and rescue it
class Lenient(BaseModel):
views: Annotated[int, WrapValidator(default_on_failure)]
for value in [12, "12", "lots", None]:
print("%-8r -> %r" % (value, Lenient(views=value).views))
print()
print("Use sparingly: swallowing an error is a decision to lose information.")
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
from typing import Annotated
from pydantic import BaseModel, BeforeValidator, AfterValidator
def b1(v):
print(" before 1"); return v
def b2(v):
print(" before 2"); return v
def a1(v):
print(" after 1"); return v
def a2(v):
print(" after 2"); return v
class M(BaseModel):
n: Annotated[int, BeforeValidator(b1), BeforeValidator(b2),
AfterValidator(a1), AfterValidator(a2)]
print("validating:")
M(n="1")
print()
print("Written left to right, before-validators run in that order and")
print("after-validators run in it too - read top to bottom as the pipeline.")
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
from pydantic import BaseModel, field_validator
class Module(BaseModel):
tags: list
title: str
@field_validator("tags", mode="before")
@classmethod
def split_csv(cls, v):
return [p.strip() for p in v.split(",")] if isinstance(v, str) else v
@field_validator("title", mode="after")
@classmethod
def titlecase(cls, v: str) -> str:
return v.strip().title()
m = Module(tags="maths, vectors", title=" the chain rule ")
print("tags :", m.tags)
print("title:", repr(m.title))
Output
after: the default
An after checker receives the value already converted. n: int given "12" reaches an after checker as the int 12.
That is what you want for nearly all. Checking membership of a set, checking against a bound that a constraint can't express, norming a string, deduplicating a list. All of these are simpler when the value is known to be the right type. You can operate on it right with no defensive isinstance.
It's also safer. An after checker can't receive an any object. It can't fail in surprising ways on input it never anticipated.
before: fixing the shape
A before checker sees the raw input. Nothing has been checked. The value could be anything.
Its purpose is repairing the *shape* of data before normal check gets to it. The canonical case is a field that should be a list, from a caller that sends a comma-split 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 isn't a list. Check fails at step 2 and step 4 is never reached. This is the single most common checker mistake. The symptom. "my checker isn't being called". Sounds like a bug in Pydantic rather than a mode choice.
Two rules for before checkers, both results of receiving unchecked input. Guard with isinstance rather than given a type. And return anything you don't handle unchanged. Normal check still runs on it.
Keep them small. Complex logic operating on unvalidated input is hard to reason about. Errors raised there lack the context that makes Pydantic's messages handy.
plain: taking over
A plain checker replaces check for that field. No coercion runs, no constraints are checked. What it returns turns the value — unvalidated.
The annotation still says int, and that now serves as docs and as the schema type rather than as a thing enforced. Your function is fully in charge for making an int.
This is the right tool for a truly custom input format. Durations like "1h30m", a coord string, a domain-exact identifier. Trying to express those with before checkers plus normal coercion is more convoluted than simply owning the parse.
The cost is that you've taken job, including making sensible errors. Raise ValueError for bad input rather than returning a thing wrong, or you've built a field that silently accepts nonsense.
wrap: around the outside
A wrap checker receives the value *and* a handler that performs the normal check. That lets it act before, after, and instead of:
The main uses are supplying a fallback instead of failing, adding context to an error before re-raising. Short-circuiting costly check for a known-good sentinel.
Use it sparingly, and be honest about the fallback case. Swallowing a ValidationError and substituting a default is a choice to lose data. The caller sent a thing wrong and will never know. That is at times right. A metrics field where a bad value shouldn't fail the whole request. And often a way to hide a bug for months.
Order
a few checkers of the same kind run in the order written. Read the annotation left to right and you're reading the pipeline top to bottom.
Mixed kinds follow the sequence: all before checkers, then coercion and constraints, then all after checkers.
That makes a norm-then-check type read on its own:
Norm, then coerce, then check. Swap the order of the metadata and the intent changes.
Two spellings
all here exists in both forms.
Annotated[T, BeforeValidator(f)] makes the rule part of a type. It's 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 truly exact to that model, or when it needs info.data to see other fields. Which the Annotated form doesn't provide.
model_validator also takes mode, with before receiving the whole raw payload and after receiving the finished model. Same vocab, 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 — before.
A format Pydantic has no idea about — plain.
Need to catch or replace a failure — wrap.
And when a checker doesn't seem to run, check the mode first. It's the answer far more often than anything else.
A worked example
A duration field accepting 90, "90" and "1h30m", rejecting nonsense. Constrained to a sensible range.
plain is the honest choice: the input format isn't a thing coercion can be expected to handle. Taking over the parse is clearer than a chain of before-checkers trying to massage "1h30m" into a thing int() will accept.
Since a plain checker skips constraints, the range check moves into the function or into a split after checker. That is the trade. Full control over parsing, full job for all downstream of it.
Modes on model validators
The same vocab applies at model level, with other scope.
model_validator(mode="before") receives the raw input for the whole payload. Often a dict. It can be anything. It's a classmethod. It returns the data to be checked. Use it to translate a legacy shape or fill in a derived key before field check runs.
model_validator(mode="after") receives the finished model as self and returns it. This is where cross-field rules belong.
There's a mode="wrap" at model level too, receiving the payload and a handler. This Can catch a whole-model failure and substitute a thing. It's 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 checker can change the shape, can't lean on the type. Its errors carry less context because nothing has been located yet.
An after checker can lean on the type, can't repair a shape that already failed. Gets good error locations for free.
A plain checker controls all and takes from nothing. No coercion, no constraints. Anything you want enforced you must enforce yourself.
A wrap checker can do all of the above and is the only one that can see a failure and decide what to do about it.
Debugging when a validator misbehaves
Three questions, in order, that resolve nearly each case.
Is it running at all? Put a print at the top. If nothing appears, the mode is wrong. Almost always an after checker whose input failed coercion first.
What type is it receiving? Print type(v). A before checker gets raw input and an after checker gets the coerced value. given the wrong one is the second most common mistake.
Is it returning? A checker with no return sets the field to None, silently. If a field turns None after you added a checker, that is the cause.
Constraints still run in the middle
A detail that is easy to forget: Field constraints sit between the two checker slots.
So Annotated[int, BeforeValidator(f), Field(gt=0), AfterValidator(g)] runs f on raw input, coerces, checks > 0, then runs g. An after checker never sees a value that failed a constraint. The constraint raised first.
That is often convenient — the after checker can assume the bounds hold — and at times the reason a checker meant to *fix* an out-of-range value never runs. Clamping belongs in before, or in the constraint's lack.
Performance
checkers are Python, and Python is the slow part of an else Rust pipeline.
For a model built a few times per request this is irrelevant. For checking a large set it's the dominant cost. Each item's checkers are a round trip out of the core and back.
If profiling points at check on a hot path, the questions are. Can this rule be a constraint instead. Constraints run in Rust. And is the checker doing work that could be done once outside rather than per item. A checker that rebuilds a set of permitted values on each call is a common and easily fixed version of the second.
Validator Modes: 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 checker doesn't run, check the mode before anything else. And note that constraints sit between the two plain slots. An after checker 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.
all else follows. A checker repairing a shape must run before. After the shape has already failed. A checker checking meaning should run after, because it can then lean on the type. Constraints sit between them. An after checker never sees a value that broke one.
plain and wrap step outside that sequence. One replacing it, the other nearby it. And both are for cases where the standard pipeline isn't what you want at all.
Keep the sentence and the rest is derivable.
Mistakes people make with Validator Modes
Shape-fixing logic in after mode. The single most common. It presents as "my checker never runs". The value failed coercion at step two and step four was never reached.
given the type in a before checker. It receives raw input. This Can be anything at all. Guard with isinstance and pass through unchanged what you don't handle. The normal path still runs.
Losing to return. Silent, and it sets the field to None. If a field mysteriously turns None after a checker is added, this is why.
Expecting constraints to run after a plain checker.plain replaces the pipeline fully. No coercion and no constraints. Anything you want enforced is now yours to enforce.
Swallowing errors in a wrap checker. Catching a ValidationError and substituting a default is a choice to lose data. The caller sent a thing wrong and will never be told. at times correct, often a bug hidden for months.
Rebuilding data inside a checker on each call. A checker that makes a set of permitted values each time it runs does that work once per item checked. Hoist it out. On a large set it's the dominant cost.
Questions people ask about Validator Modes
What are the four validator modes?after, before, plain and wrap. They differ in whether your code runs after Pydantic's own checks, before them, instead of them, or around them.
Which mode should I use by default?after. Your function receives a value that already has the right type, so you only write the extra rule.
When do I need before? When the raw input has the wrong shape for the type, such as a comma-separated string that should become a list.
What does plain do differently? It replaces Pydantic's built-in validation for that type, so you are responsible for everything, including the type check.
What is wrap for? It receives the value and a handler for the normal validation, so you can run code before and after, catch errors, or skip the handler.
In which order do multiple validators run? Inside Annotated, after validators run left to right, while before and wrap validators run right to left.
A closing note on Validator Modes
Nearly each question about checkers reduces to a question about spot.
Not "how do I write this rule" but "where in the sequence does it need to sit". Once that is settled the code is often short and obvious. When a checker behaves strangely the spot is almost always what is wrong.
Before, coerce, constrain, after. Four slots, one order. All else follows from knowing which one you're in.
Check yourself
0 of 4
Answer without scrolling back up.
Your validator turns a CSV string into a list, but it never runs. Why?
After validators run at step 4, and coercion failed at step 2. Shape-fixing logic must be in before mode, where the raw value is still available.
What does a `plain` validator skip?
It takes over completely. Whatever it returns becomes the field unvalidated, so producing the right type and raising on bad input are both your responsibility.
What does a `wrap` validator receive besides the value?
Calling the handler runs standard validation, so a wrap validator can catch its failure and substitute a value, or add context and re-raise.
In `Annotated[str, BeforeValidator(to_slug), Field(pattern=p)]`, what is the order?
The pipeline is before, coerce, constrain, after. Written the other way the pattern would reject the messy input the normaliser existed to clean.
Cheat sheet
Validator Modes
Compare before, after, plain and wrap validator modes, and see how each one interacts with Pydantic's coercion step.
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.