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
from datetime import date
from pydantic import BaseModel, model_validator, ValidationError
class Cohort(BaseModel):
starts_on: date
ends_on: date
@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
print(Cohort(starts_on="2026-09-01", ends_on="2026-12-01"))
try:
Cohort(starts_on="2026-12-01", ends_on="2026-09-01")
except ValidationError as e:
err = e.errors()[0]
print()
print("loc :", err["loc"], "<- empty: it belongs to the model, not a field")
print("said :", err["msg"])
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
from pydantic import BaseModel, model_validator, ValidationError
class Budget(BaseModel):
spent: int
total: int # declared AFTER spent
@model_validator(mode="after")
def within_budget(self):
if self.spent > self.total:
raise ValueError("spent %d exceeds total %d" % (self.spent, self.total))
return self
print(Budget(spent=40, total=100))
try:
Budget(spent=140, total=100)
except ValidationError as e:
print("refused:", e.errors()[0]["msg"])
print()
print("A field_validator on 'spent' could not have done this:")
print("'total' is declared later, so it is not in info.data yet.")
Output
At least one of these
The classic cross-field rule. Neither field is individually wrong; the combination is.
example_03.pyPydantic
from typing import Optional
from pydantic import BaseModel, model_validator, ValidationError
class Contact(BaseModel):
email: Optional[str] = None
phone: Optional[str] = None
@model_validator(mode="after")
def one_way_to_reach(self):
if not self.email and not self.phone:
raise ValueError("provide at least an email or a phone number")
return self
print(Contact(email="ada@vizlearn.in"))
print(Contact(phone="+44 20 7946 0958"))
try:
Contact()
except ValidationError as e:
print()
print("refused:", e.errors()[0]["msg"])
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
from pydantic import BaseModel, model_validator
class Module(BaseModel):
title: str
minutes: int
@model_validator(mode="before")
@classmethod
def accept_old_shape(cls, data):
if isinstance(data, dict):
data = dict(data)
# v1 of our API called these something else.
if "name" in data and "title" not in data:
data["title"] = data.pop("name")
if "duration_seconds" in data and "minutes" not in data:
data["minutes"] = round(data.pop("duration_seconds") / 60)
return data
print("new shape:", Module(title="Vectors", minutes=8))
print("old shape:", Module.model_validate({"name": "Vectors",
"duration_seconds": 480}))
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
from typing import Optional
from pydantic import BaseModel, model_validator
class Module(BaseModel):
title: str
slug: Optional[str] = None
@model_validator(mode="after")
def fill_slug(self):
if self.slug is None:
object.__setattr__(self, "slug",
self.title.lower().replace(" ", "-"))
return self
print(Module(title="The Chain Rule"))
print(Module(title="The Chain Rule", slug="chain-rule"))
print()
print("For values that are ALWAYS derived and never supplied,")
print("a computed_field is the better tool - that is the next module.")
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
from pydantic import BaseModel, model_validator, ValidationError
class Enrolment(BaseModel):
seats: int
taken: int
waitlist: int
@model_validator(mode="after")
def taken_fits(self):
if self.taken > self.seats:
raise ValueError("taken cannot exceed seats")
return self
@model_validator(mode="after")
def waitlist_only_when_full(self):
if self.waitlist and self.taken < self.seats:
raise ValueError("no waitlist while seats remain")
return self
print(Enrolment(seats=30, taken=30, waitlist=4))
for bad in [{"seats": 10, "taken": 12, "waitlist": 0},
{"seats": 10, "taken": 5, "waitlist": 3}]:
try:
Enrolment.model_validate(bad)
except ValidationError as e:
print("%-40s %s" % (str(bad), e.errors()[0]["msg"]))
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.
What must an `after` model validator return?
It receives the finished model and must return it. Forgetting is the same silent failure as forgetting to return from a field validator.
What is the `loc` of an error raised by a model validator?
The failure belongs to the object rather than any one field. Handling code that does `loc[0]` will raise IndexError the first time such a rule is added.
Why not use `info.data` in a field validator for a cross-field rule?
Fields validate in declaration order. A rule depending on that order is invisible to whoever later reorders the class for readability.
Where does 'does this track exist in the database?' belong?
Models check shape and internal consistency using only the data. A validator doing I/O makes the model untestable, turns validation into a network call, and hides a query where nobody expects one.
Cheat sheet
model_validator
Write validators that compare multiple fields at once using model_validator, and choose between before and after modes.
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.