field_validator

Rules a type cannot express, and normalisation applied before the value is ever stored.

Overview

What is left after types and constraints

Types and constraints cover a great deal, but not everything. Some rules need real logic. A slug must be lowercase with no spaces. A date must fall on a weekday. An email domain must be on an approved list. No annotation can say that, so Pydantic lets you write a function.

A field validator is a method attached to one field. Pydantic calls it after the type check, hands it the value, and takes back either the value or an error. It can also change the value on the way through, which makes it a good place to clean up input as well as reject it.

This article shows the shape of a field validator and the decorator that marks it. It covers the quiet second job, which is normalisation, such as trimming spaces or fixing case. You will see how to apply one validator to several fields at once, and how a validator can look at other fields that were already checked. Along the way it points out the common mistakes, like raising the wrong kind of error. It is the first place you write your own logic into a model.

You do not need to write a validator for every field. Most fields need none. The skill is knowing the moment when a type and a constraint have run out, and the article gives you a simple way to tell. When that moment arrives, a few lines of plain Python usually settle it.

Annotations show the kind of value. Constraints narrow it to a range, a length or a pattern. Between them they cover a great deal, and then they stop.

They can't check membership of a set that lives in a DB. They can't norm whitespace before checking a length. They can't say "if this looks like a legacy identifier, convert it". They can't express any rule whose logic is longer than a compare.

field_validator is where those live. It's a classmethod that receives one field's value. Either returns a value or raises.

Worth knowing

A validator runs after coercion by default, so the value it receives is already the annotated type.
Whatever it returns becomes the field. Forgetting the return silently sets the field to None.
Raise ValueError. Pydantic wraps it with the field's location and gives it the type value_error.
@classmethod is required and goes below @field_validator. The wrong order is a common and confusing error.
info.data exposes fields validated earlier, so declaration order decides what a validator can see. For a rule that needs everything, use model_validator.
Prefer a Field constraint when one can express the rule: constraints reach the JSON Schema, validators do not.

field_validator: Rules an Annotation Cannot Express

Custom checks and scaling, and just where in the pipeline they run.

A rule the annotation cannot hold

Anything that is not a type, a bound or a pattern needs code. A validator receives the value after coercion and either returns it or raises.

example_01.pyPydantic
Output

Returning a changed value

A validator is not only a check. Whatever it returns becomes the field, which makes it the right place for normalisation.

example_02.pyPydantic
Output

One validator, several fields

Pass more than one name, or "*" for all of them. The field being validated is available through the info argument.

example_03.pyPydantic
Output

Seeing fields already validated

info.data holds the fields validated before this one. Declaration order therefore decides what is visible.

example_04.pyPydantic
Output

Before and after coercion

mode="after" is the default and sees the converted value. mode="before" sees the raw input, which is where you fix a shape rather than a value.

example_05.pyPydantic
Output

Where a validator is the wrong tool

If a constraint can express the rule, use the constraint — it appears in the schema and a validator does not.

example_06.pyPydantic
Output

The shape

@field_validator("track")
@classmethod
def known_track(cls, v: str) -> str:
    if v not in TRACKS:
        raise ValueError("unknown track %r" % v)
    return v

Four things about that are load-bearing.

**@classmethod is needed**, and it must sit *below* @field_validator. Decorators apply bottom-up. This order gives field_validator a classmethod to register. Reversed, you get an error that doesn't obviously say what is wrong, and it's one of the most common mistakes people make with this API.

**Raise ValueError**, not ValidationError. Pydantic catches it, attaches the field's location, gives it the type value_error. Folds it into the same report as each built-in failure. Making a ValidationError yourself is awkward and not needed. AssertionError also works but is a poor choice. python -O drops assertions and your check would silently stop running.

Return the value. This is the mistake that bites hardest. It fails quietly. A checker that checks and forgets to return sets the field to None. If a field mysteriously turns None after you add a checker, this is why.

Name the failure usefully. The message goes to whoever sent the data. "unknown track 'astrology'. Try one of dsa, maths, ml, python" is worth the extra few characters over "invalid".

Validation is also normalisation

Because the returned value turns the field, a checker is the natural place to clean data.

Stripping whitespace, merging runs of spaces, lowercasing an identifier, deduplicating a list, norming a phone number. All of these belong here. Doing them here means each consumer downstream gets the clean version. The option is norming at each use site. Here One place will forget.

There's a small config-level shortcut for the most common case: str_strip_whitespace=True in model_config strips each string field. This Drops a lot of trivial checkers in one line.

Be careful about how much you transform. A checker that much rewrites its input is doing work a reader won't expect from the annotation. A caller may be surprised that what they sent isn't what came back. Norming whitespace is uncontroversial. Silently correcting a misspelt group isn't. Probably deserves to be an error instead.

Several fields at once

The decorator takes multiple names:

@field_validator("title", "summary")

And "*" applies to each field. This Is at times handy for a cross-cutting concern. Rejecting control characters, say. Though a checker that runs on each field has to be careful. It will receive values of each type.

ValidationInfo, the optional second arg, carries field_name. This Is what lets one checker give a message naming the exact field it was applied to.

Seeing other fields, and the limit of that

info.data is a dict of the fields checked *before* this one:

@field_validator("lessons")
@classmethod
def enough_time(cls, v, info):
    minutes = info.data.get("minutes")

Fields are checked in declaration order. minutes is visible to lessons only because it's declared above it. Reorder the class and the checker silently stops seeing it.

That fragility is the reason to treat info.data as a convenience rather than the tool for cross-field rules. Use .get() rather than indexing. A field that failed its own check is simply absent. A KeyError inside a checker is a much worse error than the one it was trying to report.

For any rule that truly depends on more than one field, model_validator(mode="after") is the correct tool. It runs once, after all is populated. It doesn't care what order the class was written in. That is the next module.

Before and after

By default a checker runs in mode="after". After coercion. The value is already the annotated type. That is what you want for almost each rule. You're checking a real int rather than a thing that might be a string.

mode="before" runs on the raw input, before Pydantic has tried anything. Its use is fixing the *shape* of data rather than the value:

@field_validator("tags", mode="before")
@classmethod
def split_csv(cls, v):
    if isinstance(v, str):
        return [p.strip() for p in v.split(",")]
    return v

A caller sends "maths,vectors" where a list was wanted. In after mode you'd never see it. Check would already have failed. A string isn't a list. In before mode you can convert it and let normal check proceed.

Two rules for before checkers. Accept what might arrive. The value hasn't been checked and could be anything. Guard with isinstance rather than given. And pass through anything you don't handle, unchanged. So The normal path still runs.

Validators and inheritance

checkers are from parent like any other classmethod. A base model's rules apply to each subclass. That makes a base a good home for cross-cutting scaling.

A subclass can replace a checker by defining one with the same name. This Replaces it fully rather than adding to it. If you want both, give them other names. A few checkers can target the same field and they run in meaning order.

When not to reach for one

Three cases where a checker is the wrong answer.

When a constraint would do. Field(gt=0) and a checker that checks v > 0 both reject the same values. Only the constraint appears in the JSON Schema. That means the docs says the min, the made client knows it. A form can enforce it before a request is sent. A checker is invisible to all of that.

When it needs I/O. A checker that queries a DB to check a foreign key turns check into a network call, makes the model untestable without a DB. Turns a check error into a timeout. Keep models pure. They check data using only data. Existence checks belong in the layer that owns the storage.

When it's really a type. A checker enforcing membership of four strings should be a Literal. A checker checking a value is one of a set with action should be an Enum. Both give better errors and both appear in the schema.

Several validators on one field

More than one checker can target the same field, and they run in meaning order:

@field_validator("slug")
@classmethod
def strip(cls, v): return v.strip()

@field_validator("slug")
@classmethod
def lower(cls, v): return v.lower()

Splitting rules like this is often clearer than one function doing four things. Each has a name that says what it enforces. The name matters more than it looks. It appears in tracebacks and it's what a reader scans for when asking "where is the rule about slugs?".

The counter-arg is that a chain of tiny checkers can obscure the order dependency between them. If step two only makes sense after step one, saying so in one function with two comments is honest. Splitting them and hoping no one reorders isn't.

What info carries

The optional second param is a ValidationInfo. Beyond field_name and data it has two more things.

config exposes the model's config. This Lets a checker behave differently based on, say, whether the model is strict.

context is any data you pass in at check time:

Module.model_validate(payload, context={"tenant": "acme"})

Inside a checker, info.context holds that dict. This is the supported way to give check access to a thing external without reaching for a global. A tenant, a feature flag, a set of permitted values loaded once per request.

It's truly handy and easy to overuse. A model whose rules depend heavily on context is a model that can't be understood on its own. The checks may belong in a service instead.

Reusing a rule across models

If two models need the same checker, don't copy it. There are two clean options.

Put it on a shared base model. Each subclass takes from it. Good when the models are truly related.

Or make it part of a type with AfterValidator. The rule travels with the annotation rather than with the class. That is the Annotated module's subject. It's often the better answer when the models are unrelated but the field means the same thing in both.

Copying the same @field_validator into four classes is the thing both of those exist to prevent.

Errors worth writing well

The message you raise is seen by whoever sent the data. It's worth a moment's thought.

Include what was wrong and what would be right. "unknown track 'astrology'. Try one of dsa, maths, ml, python" costs a few characters and saves a support message.

Don't include the value if it might be sensitive. The error report already carries input. A message that also embeds a token puts it in a second place.

And keep the message about the data, not the code. "check failed in known_track" tells the caller nothing they can act on.

field_validator: summary

field_validator handles what annotations and constraints can't: logic. It runs after coercion by default, receives one field. What it returns turns that field. Which makes it as much a scaling hook as a check.

note the four mechanics: @classmethod underneath, raise ValueError, always return. Use mode="before" only when you're fixing shape rather than value.

And reach for it second. Types first, constraints next, checkers for what is left.

A worked example

A tag list arriving from a form, needing three things done to it.

@field_validator("tags", mode="before")
@classmethod
def accept_csv(cls, v):
    return [p for p in v.split(",")] if isinstance(v, str) else v

@field_validator("tags")
@classmethod
def clean(cls, v: List[str]) -> List[str]:
    seen, out = set(), []
    for tag in v:
        t = tag.strip().lower()
        if t and t not in seen:
            seen.add(t)
            out.append(t)
    return out

Two checkers, two jobs, two modes. The first repairs the shape when a caller sends a string. The second norms and deduplicates once the value really is a list.

Splitting them this way is deliberate. Doing both in a single before checker would mean the cleaning logic runs on unvalidated input and has to defend itself. Doing both in after would mean the string never arrives. Each piece runs where its assumptions hold.

The result is a field that accepts "Maths, maths, VECTORS " or ["Maths", "maths", "VECTORS "] and makes ["maths", "vectors"] either way. And each consumer downstream gets the clean version without knowing any of this happened.

The order to reach for things

Types, then constraints, then checkers. Working down that list rather than up makes models where most rules are visible in the annotations and only the truly complex ones are in code. Which is also the order of how much each rule tells your schema, your docs and your consumers.

Questions people ask about field_validator

How do I write a validator for one field? Decorate a classmethod with @field_validator("title"), take the value, and return it or a changed copy. Raise ValueError to reject it.

Do I need @classmethod under @field_validator? Yes, put @classmethod directly under @field_validator. Pydantic can often cope without it, but the explicit form is what type checkers and readers expect.

Can a validator change the value? Yes. Whatever you return replaces the field value, so validators can normalise input by trimming, lowercasing or rounding.

How do I validate one field against another? Take info: ValidationInfo and read info.data, which holds fields validated earlier. For rules that need all fields at once, a model_validator is cleaner.

What exceptions should a validator raise? Raise ValueError or AssertionError, and Pydantic wraps it in a ValidationError with a value_error entry. Raise PydanticCustomError if you need your own error type.

Can one validator cover several fields? Yes. List them: @field_validator("a", "b"), or use "*" to cover every field.

Validators and the schema

Worth restating once more, because it's the trade this whole module sits inside.

A checker enforces a rule just right and tells no one. The value is rejected, the caller gets an error. Each tool that reads your schema. docs, made clients, form builders, contract tests. Remains unaware that the rule exists.

That isn't an arg against checkers. It's an arg for using them for what truly needs them. For reaching first for the annotations and constraints that can say the same thing in a form other tools can read.

When a rule can only be a checker, consider showing it in the model's docstring. This Turns the schema's description. The rule still won't be machine-readable. A human reading your docs will at least know it's there rather than discovering it through a rejection.

Check yourself

0 of 4

Answer without scrolling back up.

  1. What happens if a validator checks a value but forgets to return it?

  2. Why must `@classmethod` sit below `@field_validator`?

  3. When is `mode="before"` the right choice?

  4. Why prefer `Field(gt=0)` over a validator that checks `v > 0`?

Cheat sheet

field_validator

Write custom validation and normalisation logic with field_validator, and see exactly where each mode runs in the pipeline.

PYDANTIC · vizlearn.in/pydantic/field_validator.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.