Before, After and Wrap Validators in Pydantic v2
Key takeaways:
mode="before"sees the raw client input;mode="after"sees the coerced, typed value.mode="wrap"surrounds the whole pipeline and calls the innerhandleritself.- A recorded trace shows the true order: wrap-enter → before → coercion → wrap-exit → after.
- Only
wrapcan catch the coercionValidationErrorand substitute a value. - Choosing the wrong mode is the most common reason a validator silently never fires.
This page drills into the mode argument introduced on the custom validators and field constraints page.
The Problem This Solves
@field_validator("x", mode=...) takes one of three values and the documentation describes each in isolation. What is hard to hold in your head is their relative order, especially once wrap is involved, and getting it wrong produces a validator that either never runs or runs against a type it did not expect.
The specific confusions are: does before see the JSON string or the parsed int? If the value fails coercion, does after still run? And when a wrap validator and a before validator are on the same field, which one is on the outside?
None of that is worth guessing at. The example below records every entry and exit with the value and its Python type, so the ordering is observed rather than asserted.
Why It Happens
Pydantic v2 compiles each field into a small schema in the Rust core. That schema is a chain: the coercion step for the declared type sits in the middle, and validators wrap around it in layers. before validators are layers on the input side, after validators are layers on the output side, and a wrap validator is a layer that receives the rest of the chain as a callable — the handler argument.
Because wrap receives the remaining chain, it is necessarily outside everything the chain contains, including any before validator. That is the piece people get backwards, and it is what the trace below settles.
Prerequisites
- Pydantic 2.13.4, FastAPI 0.139.2, Python 3.12.
- The mode argument works the same on
@field_validatorand onAnnotatedmarkers (BeforeValidator,AfterValidator,WrapValidator).
Step-by-Step Implementation
1. Instrument all three modes on one field
The model below declares celsius: int and attaches a validator in each mode. Each one appends to a module-level list with the value and its type, so the resulting trace is a literal record of the order.
"""before / after / wrap validator modes: firing order relative to type coercion."""
from typing import Any
from fastapi import FastAPI
from pydantic import BaseModel, ValidationError, ValidationInfo, field_validator
app = FastAPI()
TRACE: list[str] = []
class Reading(BaseModel):
"""Every mode records when it fired and what type it was handed."""
celsius: int
@field_validator("celsius", mode="before")
@classmethod
def trace_before(cls, value: Any) -> Any:
TRACE.append(f"before: {value!r} ({type(value).__name__})")
return value
@field_validator("celsius", mode="wrap")
@classmethod
def trace_wrap(cls, value: Any, handler, info: ValidationInfo) -> Any:
TRACE.append(f"wrap-enter: {value!r} ({type(value).__name__})")
try:
result = handler(value)
except ValidationError as exc:
TRACE.append(f"wrap-caught: {exc.errors()[0]['type']}")
raise
TRACE.append(f"wrap-exit: {result!r} ({type(result).__name__})")
return result
@field_validator("celsius", mode="after")
@classmethod
def trace_after(cls, value: int) -> int:
TRACE.append(f"after: {value!r} ({type(value).__name__})")
return value
2. Read the recorded order
Real output from _verify/output/pyd-wrap-validators.txt:
$ POST /readings/trace {"celsius": 21}
200 OK
{
"outcome": "valid",
"trace": [
"wrap-enter: 21 (int)",
"before: 21 (int)",
"wrap-exit: 21 (int)",
"after: 21 (int)"
]
}
$ POST /readings/trace {"celsius": "21"}
200 OK
{
"outcome": "valid",
"trace": [
"wrap-enter: '21' (str)",
"before: '21' (str)",
"wrap-exit: 21 (int)",
"after: 21 (int)"
]
}
$ POST /readings/trace {"celsius": "warm"}
200 OK
{
"outcome": "rejected: 1 error(s)",
"trace": [
"wrap-enter: 'warm' (str)",
"before: 'warm' (str)",
"wrap-caught: int_parsing"
]
}
Every question posed at the top is answered by those three traces.
wrap is outermost. wrap-enter precedes before in all three runs. If you have both on a field, the wrap validator sees the value first.
before really does see the raw type. In the second run the field is declared int and the before validator is handed '21' as a str. It is only after handler() returns that the value is 21 as an int — visible in the wrap-exit line. This is the whole reason before is the place for normalization: strip whitespace, parse a bespoke date format, unwrap a {"value": ...} envelope — anything that turns a shape Pydantic cannot coerce into one it can.
after does not run on failure. The third trace ends at wrap-caught. There is no after line, because coercion raised and the chain never produced a typed value to hand it. An after validator is therefore guaranteed its input is already the declared type — you never need to isinstance-check inside one.
wrap sees the actual error. wrap-caught: int_parsing is the error type from the inner handler, captured by the except ValidationError block. Neither of the other modes can observe this.
3. Use the caught error
Catching is only interesting if you do something other than re-raise. The second model in the example substitutes a documented sentinel instead of failing:
class Temperature(BaseModel):
"""A wrap validator that turns an unparseable value into a documented default."""
celsius: int = 0
@field_validator("celsius", mode="wrap")
@classmethod
def default_on_garbage(cls, value: Any, handler) -> int:
try:
return handler(value)
except ValidationError:
# The inner handler already ran coercion and constraints and rejected the value.
return -273
Real output:
$ POST /temperatures/ {"celsius": "warm"}
200 OK
{
"celsius": -273
}
$ POST /temperatures/ {"celsius": "18"}
200 OK
{
"celsius": 18
}
"warm" becomes -273 with a 200; "18" coerces normally. For contrast, the same input against the plain Reading model through a request body produces the ordinary rejection:
$ POST /readings/ {"celsius": "warm"}
422 Unprocessable Entity
{
"detail": [
{
"type": "int_parsing",
"loc": [
"body",
"celsius"
],
"msg": "Input should be a valid integer, unable to parse string as an integer",
"input": "warm"
}
]
}
Choosing a Mode
| You want to… | Mode |
|---|---|
| Trim, lowercase, or reshape input before coercion | before |
| Parse a custom wire format into something coercible | before |
| Assert a business rule on the final typed value | after |
| Clamp or round an already-valid number | after |
| Replace a coercion failure with a fallback | wrap |
| Time or log the cost of validating one field | wrap |
| Enrich the error message with domain context | wrap |
The signature differs per mode. before and after take (cls, value) and optionally (cls, value, info). wrap takes (cls, value, handler) and optionally (cls, value, handler, info) — the handler is positional and required.
Edge Cases and Gotchas
beforereceives anything at all.Anyis the honest annotation. A validator that doesvalue.strip()without anisinstance(value, str)guard raisesAttributeError— not aValidationError— the first time a client sendsnullor a number, and that becomes a 500.- Not calling
handler. Awrapvalidator that returns without invoking the handler skips coercion and every constraint entirely. Whatever you return is stored on the model as-is, even if its type is wrong. This is occasionally the point, and much more often a bug. - Swallowing errors is invisible. The
Temperaturemodel returns a 200 for input the schema says is invalid. Callers get no signal. If you do this, log it, and preferOptionalwith an explicitNoneover a magic sentinel where the API contract allows. aftermode is not a formatting hook. Returning astrfrom anaftervalidator on anintfield stores astron the model; there is no re-coercion afterwards. Presentation belongs in a serializer.Fieldconstraints live with coercion.Field(gt=0)is enforced inside the handler, not in anafterlayer, so awrapvalidator'sexcept ValidationErrorcatches constraint violations as well as parse failures. Checkexc.errors()[0]["type"]if you only mean to handle one of them.
Verification
The trace technique is worth keeping as a test rather than throwing away after the investigation:
def test_wrap_is_outermost():
TRACE.clear()
Reading(celsius="21")
assert TRACE[0].startswith("wrap-enter")
assert TRACE[1].startswith("before")
assert TRACE[-1].startswith("after")
def test_after_does_not_run_on_coercion_failure():
TRACE.clear()
with pytest.raises(ValidationError):
Reading(celsius="warm")
assert not any(line.startswith("after") for line in TRACE)
Trade-offs and When Not To
wrap is the mode to reach for last. It is the only one that can hide a failure, and a wrap validator that swallows errors turns a loud 422 into a quiet wrong answer that surfaces days later in a report. It also costs more: the Python callable is on the hot path with a closure over the remaining chain, invoked for every instance constructed. On a model validated thousands of times per second, prefer a declarative Field constraint, then before/after, and only then wrap.
The legitimate uses are narrow and real: tolerating a legacy client that sends "" for absent numbers, adding domain context to an otherwise cryptic parse error, or instrumenting which field in a large model is actually expensive to validate. Outside those, one of the simpler modes will do the job with less to explain in review. For rules that span more than one field, none of these modes is right — that is cross-field validation territory.
FAQ
What is the difference between before, after and wrap validator modes?
A before validator runs on the raw input ahead of type coercion and can return anything. An after validator runs once the value is coerced to the declared type and receives that typed value. A wrap validator sits around the whole inner pipeline, receives a handler it calls explicitly, and can act on both sides of it including catching the inner ValidationError.
When does a wrap validator run relative to a before validator?
The wrap validator is entered first. Its handler call is what triggers the before validator and coercion, and control returns to the wrap validator afterwards. A recorded trace shows wrap-enter, then before, then wrap-exit, then after.
Can a wrap validator swallow a validation error and return a default?
Yes. Calling the inner handler inside a try/except ValidationError lets the wrap validator return a fallback instead of propagating. This is the only mode that can do it, because before and after validators never see the coercion error.
Why does my before validator receive a string when the field is an int?
Because before runs ahead of coercion, so it sees exactly what the client sent. If the JSON body had a quoted number the validator receives a str, and only after the handler runs does it become an int.
Should I use wrap mode by default?
No. Wrap is the most powerful and the most opaque mode; it hides failures unless you re-raise. Use before for normalization, after for assertions on the typed value, and reserve wrap for the specific case where you need to observe or alter the coercion failure itself.
Related Reading
- Up to the topic: custom validators and field constraints covers
Fieldconstraints and when a function is not needed at all. - Packaging a mode onto a reusable type is covered in creating reusable custom validators; the async caveat is in Pydantic v2 async custom validators.
- Model-scope modes work the same way — see @root_validator to @model_validator.
- The 422 bodies these validators produce can be reshaped; see customising validation error responses.