model_validator

Rules that span fields - the ones a per-field validator structurally cannot see.

Overview

Why a field validator is not enough

A field validator sees one field. Some rules cannot be checked that way, because they depend on two or more fields together. A start date must come before an end date. A password must match its confirmation. If a discount is present, a reason must be present too.

A model validator runs against the whole object, so it can compare fields with each other. It is the right tool for any rule that spans fields. It comes in two main flavours. One runs after the fields are validated, and sees clean, typed values. The other runs before, and sees the raw input.

This article starts with why a field validator is not enough. It covers the after mode first, since that is the one you will use most. It explains the empty location in the error, which is how a model-level failure shows up. Then it covers the before mode, where you can reshape the raw input. The last section shows how to set values from a model validator rather than only checking them. Together these cover the cross-field rules that real forms and APIs always seem to need.

These rules are common in forms, bookings and anything with a range. The article keeps each case small.

A field_validator receives one value. That is the right shape for most rules. It's structurally incapable of expressing a large and key class of them.

An end date must be after a start date. A discount must not exceed the price. Either an email or a phone number must be present. A waitlist only makes sense when each seat is taken. None of these is a statement about a single field. Each field is on their own fine. It's the mix that is wrong.

info.data looks like a way round this. It half is. It exposes fields checked *earlier*. This Means the rule only works if the fields happen to be declared in the right order and silently stops working when somebody reorders the class for tidiness. That isn't a foundation to build on.

model_validator is the tool built for the job.

Worth knowing

mode="after" receives the finished model as self and must return self.
mode="before" receives the raw input for the whole model, is a @classmethod, and must return the data to validate.
A model-level error has an empty loc, because it belongs to the object rather than any one field. Code that assumes loc[0] exists will break on it.
Unlike field_validator with info.data, a model validator does not depend on declaration order — every field is already present.
Validators run in definition order and the first raise stops the rest, so put cheap checks before expensive ones.
Keep them pure. A model validator that queries a database makes validation an I/O operation and the model untestable on its own.

model_validator: Rules That Span Fields

The checks a per-field checker structurally can't make. The two modes that make them.

A rule about two fields

mode="after" runs once, on the finished model, with every field populated and converted. Return self.

example_01.pyPydantic
Output

Order does not matter here

A field validator can only see fields declared above it. A model validator sees all of them, however the class is written.

example_02.pyPydantic
Output

At least one of these

The classic cross-field rule. Neither field is individually wrong; the combination is.

example_03.pyPydantic
Output

mode=before: reshaping the whole payload

A before validator receives the raw input for the entire model. It is where you accept a legacy shape and translate it.

example_04.pyPydantic
Output

Deriving a field from others

An after validator can set fields as well as check them — useful when one value should be filled in from the rest.

example_05.pyPydantic
Output

Several rules, and where they report

Model validators run in definition order, after every field. The first to raise stops the rest, so put the cheapest check first.

example_06.pyPydantic
Output

mode="after"

The common case. It runs once, after each field has been checked and converted, and receives the finished model:

@model_validator(mode="after")
def ends_after_start(self):
    if self.ends_on <= self.starts_on:
        raise ValueError("ends_on must be after starts_on")
    return self

Three mechanics. It takes self, not cls. Isn't a classmethod. Which is the opposite of field_validator and catches people out. It must **return self**, and losing is the same silent failure as losing to return from a field checker. And by the time it runs, the fields are real Python objects. self.ends_on is a date and checking it works.

That last point is worth dwelling on. Because coercion has already happened, an after checker can compare, subtract and sort without any defensive conversion. The rule reads just like the sentence you'd say out loud.

The empty location

An error raised here has loc: () — an empty tuple.

That is correct: the failure belongs to the object, not to any single field. There's no one input to highlight. The problem is the link between two of them.

It's also the thing most likely to break error-handling code written before the first cross-field rule was added. Anything doing err["loc"][0] will raise IndexError. It will do so the first time somebody adds a checker like this. Long after the handler was written and tested.

Handle it by name. Group under a key like "_form". Give the interface one spot to display an error that isn't attached to an input. each form library has a concept for this. The model just needs to feed it.

mode="before"

A before checker receives the raw input for the entire model, before any field has been looked at. It's a classmethod. It returns the data that will then be checked most times.

Its main use is accepting a shape you didn't design:

@model_validator(mode="before")
@classmethod
def accept_old_shape(cls, data):
    if isinstance(data, dict) and "name" in data:
        data = dict(data)
        data["title"] = data.pop("name")
    return data

This is the translation layer for a legacy payload, a third-party API with other names, or a version of your own format you no longer want in the model. The model stays clean and shows the shape you want. The adapter sits in one visible place.

Two rules, both the same as for field-level before checkers. Guard with isinstance. The input hasn't been checked and may not be a dict at all. And copy before mutating. dict(data). Because modifying the caller's dict in place is a surprise no one enjoys debugging.

Use before sparingly. It runs before all. Any error it raises is reported without the field context that makes Pydantic errors handy. Complex logic there's hard to follow. For anything that is truly per-field, a field checker says more.

Setting values, not just checking

An after checker can modify the model. This Makes it a way to derive one field from others:

@model_validator(mode="after")
def fill_slug(self):
    if self.slug is None:
        object.__setattr__(self, "slug", self.title.lower().replace(" ", "-"))
    return self

The object.__setattr__ is needed on a frozen model and is harmless else. On a mutable model a plain set works, though it will re-trigger check if validate_assignment is on.

Before reaching for this, ask whether the value is ever legitimately supplied by the caller. If it's. A slug that defaults from the title but can be overridden. This is right. If it never is. Is always derived, then it isn't really an input at all and computed_field is the better tool. That is the next module.

Order, and short-circuiting

a few model checkers can coexist. They run in meaning order. The first to raise stops the rest.

That has a hands-on result: put the cheap, foundational checks first. If taken > seats is nonsense, there's no value in also checking a rule about the waitlist that assumes those numbers make sense. And the second error would only confuse the caller.

It also means model checkers don't build up errors the way field check does. Field errors all appear together. Model-level errors appear one at a time. If you want a caller to see each cross-field problem at once, you've to collect them yourself in a single checker and raise one error showing all of them.

Keeping it pure

The strongest advice in this module: a model checker should decide using only the data in front of it.

The temptation is real. "Does this track exist?" is a check question. The answer is in a DB. Putting the query in a checker makes it run on each build, makes the model hopeless to test without a DB, turns a ValidationError into a possible timeout. Hides an I/O call one spot no one expects one.

The rule that keeps this clean: models check *shape and inner agreement*. The service layer checks *facts about the world*. A date range being backwards is shape. A track existing is a fact. They're other concerns and they fail differently. One is a 422, the other is arguably a 404.

Choosing between the three tools

A constraint when the rule is a bound, a length or a pattern on one field. It reaches the schema.

A field checker when one field needs logic, or norming.

A model checker when the rule involves more than one field, or the shape of the whole payload.

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.

Collecting several problems at once

Model checkers stop at the first failure. This Means a caller fixing cross-field errors discovers them one at a time. Just the experience field check avoids.

If a few rules should report together, collect them in one checker:

@model_validator(mode="after")
def check_all(self):
    problems = []
    if self.ends_on <= self.starts_on:
        problems.append("ends_on must be after starts_on")
    if self.seats < self.taken:
        problems.append("seats cannot be fewer than taken")
    if problems:
        raise ValueError("; ".join(problems))
    return self

It's less tidy than split checkers and it gives the caller all in one response. Which matters depends on whether a human is fixing a form or a service is failing a request.

Validators and assignment

With validate_assignment=True, after checkers run again on each set.

That is often what you want. A cross-field rule should hold after a change, not only at build. It has two results worth knowing.

An costly checker now runs on each set, not once.

And an mid state may be invalid. Setting starts_on to a date after the current ends_on raises, though you were about to fix ends_on on the next line. There's no transaction. Each set is checked alone.

Where that bites, the functional approach is cleaner. Build a new model with model_copy(update={...}) giving both fields at once, or make a fresh one. It's also a good arg for frozen=True on models with cross-field rules. If it can't be mutated, it can't pass through an invalid mid state.

Inheritance

Model checkers are from parent. So A base can carry an rule that each subclass enforces.

A subclass redefining a checker with the same name replaces it. Give it a other name to have both. Note that the parent's runs first.

This makes a base model a fair home for a rule shared across a family. "no model in this system may have an end before its start". While each subclass adds its own.

What belongs where, once more

The line worth holding, because it's the one people cross first.

A model checker answers: is this object inside steady? Dates in order, totals adding up, at least one contact method present. All answerable from the data in front of it.

A service answers: is this true of the world? Does the track exist, is the name taken, does this user have permission. All needing a thing the model can't see.

Keeping that line means models are testable with plain data, check can't make a network call. A ValidationError always means the payload was malformed rather than that a thing external was down. Those are three properties worth protecting.

model_validator: summary

model_validator(mode="after") takes self, returns self, and sees each field already converted. The right place for any rule about links between fields. mode="before" is a classmethod taking raw input, for translating a payload shape.

Model-level errors carry an empty loc. This Your error handling needs to expect. checkers run in meaning order and stop at the first failure. And keep them pure. That checking a model never touches the world.

A short checklist

Before writing one, three questions.

Does the rule involve more than one field? If not, a field checker or a constraint is more exact and gives a better error location.

Can it be decided from the data alone? If it needs a lookup, it belongs in the service layer, not here.

Should the caller see each failure at once? If so, collect them in a single checker rather than writing a few that stop at the first.

What this buys

A model with cross-field rules is a model that can't exist in a nonsensical state. A cohort whose end precedes its start isn't merely flagged one spot. It can't be made.

That is a strong promise, and it's what makes the rest of the code simpler. each function receiving that model can stop checking. The object couldn't have been built if the check would have failed. The rule exists once, at the boundary, instead of being re-asserted defensively wherever the data travels.

Mistakes people make with model_validator

**Using info.data in a field checker for a cross-field rule.** It only exposes fields declared earlier. The rule works until somebody reorders the class for readability and then silently stops.

**Losing to return self.** The same silent failure as all over else in this library.

Doing I/O. A checker that queries a DB makes the model untestable without one, turns a ValidationError into a possible timeout. Hides a network call one spot no one expects one.

Writing a few checkers when the caller needs each failure at once. Model checkers stop at the first raise. A form with three cross-field problems reveals them one at a time. Collect them into one checker when they should be reported together.

**given loc[0] exists.** Model-level errors carry an empty loc. Error-handling code written before the first cross-field rule will raise IndexError the day one is added.

**Mutating in an after checker on a model with validate_assignment.** The set re-triggers check. This Re-runs the checker. Use object.__setattr__, or set the value in a before checker instead.

Questions people ask about model_validator

When do I need model_validator instead of field_validator? When a rule depends on more than one field, such as end > start or password confirmation. A field validator only sees its own value plus the fields before it.

What is the difference between mode="before" and mode="after"? before receives the raw input, often a dict, before any field is validated. after receives the finished model instance and can check relationships between fields.

Is an after validator a classmethod? No, an after validator is an ordinary instance method that returns self. A before validator is a classmethod that receives the raw data.

Why does the error for my model validator have an empty location? The problem belongs to the whole model, not one field, so loc is an empty tuple. Raise from a field validator if you want the error attached to a field.

Can a validator modify the model? Yes. In an after validator you can set attributes on self before returning it, though assigning with validate_assignment on can cause recursion.

Do model validators run if a field already failed? A before validator always runs first. An after validator is skipped when field validation failed, since there is no valid instance to check.

Where the errors go

One hands-on result of the empty loc deserves a final mention. It shapes how the front end has to work.

A field error can be rendered beside its input. A model error can't. There's no single input it belongs to. each form needs one spot to display it. A banner above the fields, a summary at the top, a message near the submit button.

If that place doesn't exist, cross-field errors are either invisible or attached arbitrarily to whichever field the code happened to pick. Both are worse than a plain sentence in an obvious place.

It's a small piece of interface design that follows right from a modelling choice. It's easiest to get right by knowing it's coming.

Check yourself

0 of 4

Answer without scrolling back up.

  1. What must an `after` model validator return?

  2. What is the `loc` of an error raised by a model validator?

  3. Why not use `info.data` in a field validator for a cross-field rule?

  4. Where does 'does this track exist in the database?' belong?

Cheat sheet

model_validator

Write validators that compare multiple fields at once using model_validator, and choose between before and after modes.

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