Migrating @root_validator to @model_validator in Pydantic v2
Key takeaways:
@root_validator(pre=True)becomes@model_validator(mode="before")— still a classmethod, still receives raw input.@root_validator()becomes@model_validator(mode="after")— now an instance method receivingself, not avaluesdict.- Subscripting
selfin anaftervalidator raisesTypeError, which is not converted to a 422. mode="before"input is not guaranteed to be a dict; guard withisinstance.- A model-level failure reports
loc: ["body"], not a field name.
This is one of two decorator migrations in the Pydantic V2 Migration Guide; the field-level equivalent is migrating @validator to @field_validator.
The Problem This Solves
@root_validator was the v1 tool for any rule spanning more than one field. It came in two flavours, both classmethods, both handed a mutable values dict:
# Pydantic v1 — the idiom being replaced. NOT executable on v2.
class Order(BaseModel):
quantity: int
unit_price: float
total: float
@root_validator(pre=True)
def fill_total(cls, values):
if "total" not in values:
values["total"] = values["quantity"] * values["unit_price"]
return values
@root_validator()
def total_must_match(cls, values):
if values["total"] != values["quantity"] * values["unit_price"]:
raise ValueError("total does not match")
return values
The mechanical part of the migration is easy: rename the decorator and pass a mode. The part that bites is that the post flavour changed shape entirely. In v1 both flavours saw a dict; in v2 only before does.
Why It Happens
The v1 values dict in a post-root-validator was a partial, untyped view of the model under construction. If any field had failed, it was simply absent — which is why v1 root validators were full of values.get("x") and if "x" in values defensiveness, and why skip_on_failure=True existed at all.
Pydantic v2 restructured this. Field validation runs to completion first. If any field fails, the model is never constructed and after validators never run — there is no partial state to defend against. If every field succeeds, the core builds the model instance and passes that to each after validator. So the argument is self, the fields are typed, and self.total is a float rather than whatever the client sent.
That is the whole reason for the signature change: after runs on the other side of construction, so there is no dict left to hand you.
before gets a dict and after gets the model.Prerequisites
- Pydantic 2.13.4 and FastAPI 0.139.2 — every transcript below came from that pair.
- Familiarity with custom validators and field constraints, since model validators run after field ones.
Step-by-Step Implementation
1. Translate pre=True to mode="before"
The before case is almost a straight rename. It is still a classmethod, it still receives the raw input, it still returns whatever the next stage should validate. The one change worth making while you are in there is to stop assuming a dict.
2. Translate the post validator to mode="after"
Drop @classmethod. Change the signature to (self) -> "Model". Replace every values["x"] with self.x. Return self.
3. Delete skip_on_failure
It no longer exists and is no longer needed: after validators only run when every field validated.
Here is the migrated model, run for real:
"""Migrating v1 @root_validator to v2 @model_validator, including the wrong-signature error."""
import json
from typing import Any
from fastapi import FastAPI
from pydantic import BaseModel, ValidationError, model_validator
app = FastAPI()
class Order(BaseModel):
"""mode='before' receives the raw input mapping, exactly like v1's root_validator(pre=True)."""
quantity: int
unit_price: float
total: float
@model_validator(mode="before")
@classmethod
def fill_total(cls, data: Any) -> Any:
if isinstance(data, dict) and "total" not in data:
data = {**data, "total": data["quantity"] * data["unit_price"]}
return data
@model_validator(mode="after")
def total_must_match(self) -> "Order":
# mode='after' gets the constructed model, so fields are typed and attribute-accessed.
if abs(self.total - self.quantity * self.unit_price) > 1e-9:
raise ValueError("total does not match quantity * unit_price")
return self
class BrokenOrder(BaseModel):
"""The common migration mistake: an 'after' validator written as if it received a dict."""
quantity: int
unit_price: float
@model_validator(mode="after")
def subscript_like_v1(self):
# WRONG: in mode='after' `self` is the model, not the v1 `values` dict.
if self["quantity"] < 1:
raise ValueError("quantity must be positive")
return self
Real output from _verify/output/pyd-root-to-model-validator.txt:
$ POST /orders/ {"quantity": 3, "unit_price": 12.5}
200 OK
{
"quantity": 3,
"unit_price": 12.5,
"total": 37.5
}
$ POST /orders/ {"quantity": 3, "unit_price": 12.5, "total": 99.0}
422 Unprocessable Entity
{
"detail": [
{
"type": "value_error",
"loc": [
"body"
],
"msg": "Value error, total does not match quantity * unit_price",
"input": {
"quantity": 3,
"unit_price": 12.5,
"total": 99.0
},
"ctx": {
"error": {}
}
}
]
}
$ POST /orders/broken {"quantity": 3, "unit_price": 12.5}
200 OK
{
"exception_type": "TypeError",
"message": "'BrokenOrder' object is not subscriptable"
}
The first request shows the before validator doing its job: total was absent from the payload and the response contains 37.5. The second shows the after validator rejecting an inconsistent payload.
The third is the point of the page. BrokenOrder is what a mechanical migration produces — decorator renamed, body untouched — and the endpoint catches the exception so it can be printed. The real message is 'BrokenOrder' object is not subscriptable, and its type is TypeError.
That type matters enormously. Pydantic converts ValueError and AssertionError raised inside validators into validation errors. It does not convert TypeError. So a half-migrated after validator does not produce a 422 telling you the input was bad — it propagates out of model construction, past FastAPI's request-validation handling, and becomes a 500 with a traceback. In a codebase with dozens of root validators, a handful of them turning into 500s only on the code paths where they were reached is a genuinely unpleasant way to discover the migration was incomplete.
Reading the 422 Shape
Note the loc in the second response: ["body"]. Not ["body", "total"]. A model validator's failure belongs to the model, so Pydantic has no field to attribute it to. Clients that build form-field error maps by reading loc[-1] will get "body" for every cross-model rule.
Note also "ctx": {"error": {}} — that is FastAPI's JSON encoding of the original ValueError object in the error context, which does not serialize to anything useful. Calling ValidationError.json() directly instead gives the readable form. Same failure, captured through exc.json():
$ POST /orders/validation-error {"quantity": 3, "unit_price": 12.5, "total": 99.0}
200 OK
{
"errors": [
{
"type": "value_error",
"loc": [],
"msg": "Value error, total does not match quantity * unit_price",
"input": {
"quantity": 3,
"unit_price": 12.5,
"total": 99.0
},
"ctx": {
"error": "total does not match quantity * unit_price"
},
"url": "https://errors.pydantic.dev/2.13/v/value_error"
}
]
}
Standalone, the loc is [] — FastAPI prepends "body" when the model is a request body. If you are reshaping these responses, see customising validation error responses.
Edge Cases and Gotchas
@classmethodon the wrong mode.beforeneeds it;aftermust not have it. Decorating anaftervalidator with@classmethodmeans the first argument is the class, not the instance, so attribute access reads class-level metadata instead of the validated values.- Forgetting to return. Both modes must return —
beforereturns the data,afterreturnsself. A missingreturnin anaftervalidator makes Pydantic treatNoneas the validated model. - Mutating in
after. You can assign toselfin anaftervalidator, but by default that assignment is not re-validated unlessvalidate_assignmentis set. Assigning a value that violates the field's own type will stick. beforeinput is not always a dict.model_validateaccepts arbitrary objects, and withfrom_attributes=Trueit accepts ORM rows. Theisinstance(data, dict)guard in the example is not decoration; without it the validator raisesTypeErrorthe first time anyone constructs the model from a row.- Ordering between multiple validators. When a model carries several validators of the same mode, the order they fire in is a detail of how the core assembles the schema rather than something to design around. If two rules interact, merge them into one validator so the sequence is explicit in the code.
Verification
Test the failure path explicitly, and assert on the error type rather than just that something raised:
import pytest
from pydantic import ValidationError
def test_total_mismatch_is_a_validation_error():
with pytest.raises(ValidationError) as exc_info:
Order(quantity=3, unit_price=12.5, total=99.0)
assert exc_info.value.errors()[0]["type"] == "value_error"
def test_before_validator_fills_total():
assert Order(quantity=3, unit_price=12.5).total == 37.5
During the migration itself, the highest-value check is a grep: search for values[ inside anything decorated @model_validator(mode="after"). Every hit is a latent 500.
Trade-offs and When Not To
mode="after" is the right default for cross-field rules and you should reach for it first. But it runs on every construction of the model, including internal ones — deserializing from a cache, rebuilding from the database — and a rule expressed as an invariant will reject data that was already persisted. If a rule is really a request-time policy rather than a property of the data, put it in the endpoint or the service layer, where it can be applied selectively and where it can be async.
mode="before" is powerful precisely because it runs on unvalidated input, which is also why it is a poor place for anything but shape normalization. Computing a derived field there, as fill_total does, is fine. Doing arithmetic there on values you have not confirmed are numbers is how you get a TypeError instead of a 422.
FAQ
Why does model_validator(mode='after') receive self instead of a values dict?
Because by that point validation has already succeeded and the model instance exists. Pydantic v2 constructs the model first, then hands it to after validators, so fields are typed and accessed as attributes. There is no dict to pass because the dict stage is over.
What error do I get if I subscript self in an after validator?TypeError: 'Model' object is not subscriptable. Because it is a TypeError rather than a ValueError, Pydantic does not convert it into a validation error, so it propagates out of model construction and surfaces as a 500 from FastAPI rather than a 422.
Does model_validator(mode='before') need the @classmethod decorator?
Yes. A before validator runs before any instance exists, so it is a classmethod receiving the raw input. An after validator is an instance method taking self and must not be decorated with classmethod.
What is the loc of a model_validator error in the 422 response?
It is the location of the model itself, not a field. For a request body the loc is ["body"] with type value_error, because the failure belongs to the whole model rather than to any one field.
Can a before validator receive something other than a dict?
Yes. model_validate can be handed an arbitrary object, and with from_attributes it can be an ORM row. Always guard with isinstance before subscripting, or the validator breaks for inputs it was never tested against.
Related Reading
- Up to the topic: the Pydantic V2 Migration Guide.
- The field-level counterpart is migrating @validator to @field_validator, and the whole-migration sequencing lives in migrating without breaking APIs.
- For what to write once the decorator is migrated, see cross-field validation patterns and before, after and wrap validators.
- Serialization moved in the same release; see replacing json_encoders with field_serializer.