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 inner handler itself.
  • A recorded trace shows the true order: wrap-enter → before → coercion → wrap-exit → after.
  • Only wrap can catch the coercion ValidationError and 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.

Nesting of wrap, before, coercion and after validatorsThe wrap validator is the outermost layer. Inside it sits the before validator, then type coercion and field constraints, and the after validator runs on the way back out.mode="wrap" — calls handler(value)mode="before" — raw valuetype coercion + Field constraintsraises ValidationError on failuremode="after" — runs on the typed value once the chain returns
The wrap validator is the outermost layer, so it is entered before the before validator and exits before the after validator runs.

Prerequisites

  • Pydantic 2.13.4, FastAPI 0.139.2, Python 3.12.
  • The mode argument works the same on @field_validator and on Annotated markers (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 coercionbefore
Parse a custom wire format into something coerciblebefore
Assert a business rule on the final typed valueafter
Clamp or round an already-valid numberafter
Replace a coercion failure with a fallbackwrap
Time or log the cost of validating one fieldwrap
Enrich the error message with domain contextwrap

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

  • before receives anything at all. Any is the honest annotation. A validator that does value.strip() without an isinstance(value, str) guard raises AttributeError — not a ValidationError — the first time a client sends null or a number, and that becomes a 500.
  • Not calling handler. A wrap validator 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 Temperature model returns a 200 for input the schema says is invalid. Callers get no signal. If you do this, log it, and prefer Optional with an explicit None over a magic sentinel where the API contract allows.
  • after mode is not a formatting hook. Returning a str from an after validator on an int field stores a str on the model; there is no re-coercion afterwards. Presentation belongs in a serializer.
  • Field constraints live with coercion. Field(gt=0) is enforced inside the handler, not in an after layer, so a wrap validator's except ValidationError catches constraint violations as well as parse failures. Check exc.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.