Cross-Field Validation Patterns in Pydantic v2
Key takeaways:
- Any rule involving two fields belongs in
@model_validator(mode="after"), where every field is present and typed. @field_validatorsees only fields declared above it, viainfo.data— reorder the class and the rule stops firing, with no error.- Cross-field failures report
loc: ["body"], not a field name. Plan your client error handling around that. - The three recurring shapes are range ordering, matching confirmation, and conditional requirement.
- Conditional requirements stay optional in the JSON Schema; the constraint is invisible to generated clients.
This page builds on custom validators and field constraints, which covers single-field rules.
The Problem This Solves
Some rules cannot be expressed on one field. ends_on is only wrong relative to starts_on. password_confirm is only wrong relative to password. address is only required when method == "delivery". None of these fit a Field constraint or a @field_validator, and the natural first attempt — reach for field_validator and look at the other field — has a failure mode that is worse than an exception: it silently does nothing.
Why It Happens
Pydantic v2 validates fields in declaration order. A field_validator can accept a ValidationInfo argument whose .data attribute holds the fields validated so far. That is genuinely useful and genuinely dangerous, because .data is a partial dict, and everything about it depends on declaration order:
- A field declared after the validator's field is simply not in
.data. - A field that failed its own validation is not in
.dataeither.
So info.data.get("starts_on") returns None in both cases, and any rule guarded by if start is not None quietly skips. Nothing raises. The endpoint returns 200 and accepts the invalid payload.
@model_validator(mode="after") has neither problem. It runs only once every field has validated, and it receives the constructed model, so self.starts_on is a real date. There is no ordering to remember and no partial state to defend against.
info.data contains; a model validator runs after construction and is not affected by it.Prerequisites
- Pydantic 2.13.4 and FastAPI 0.139.2 — the versions behind every transcript here.
- The
aftermode semantics from before, after and wrap validators.
Pattern 1: Date Range Ordering
The canonical case. One model_validator compares the two dates and raises.
class DateRange(BaseModel):
starts_on: date
ends_on: date
@model_validator(mode="after")
def end_after_start(self) -> "DateRange":
if self.ends_on <= self.starts_on:
raise ValueError("ends_on must be later than starts_on")
return self
Real output from _verify/output/pyd-cross-field.txt:
$ POST /ranges/ {"starts_on": "2026-08-01", "ends_on": "2026-08-10"}
200 OK
{
"starts_on": "2026-08-01",
"ends_on": "2026-08-10"
}
$ POST /ranges/ {"starts_on": "2026-08-10", "ends_on": "2026-08-01"}
422 Unprocessable Entity
{
"detail": [
{
"type": "value_error",
"loc": [
"body"
],
"msg": "Value error, ends_on must be later than starts_on",
"input": {
"starts_on": "2026-08-10",
"ends_on": "2026-08-01"
},
"ctx": {
"error": {}
}
}
]
}
Two details to note. loc is ["body"] — the failure has no field. And input echoes the entire submitted body. That is convenient for debugging and a liability everywhere else: on a model containing anything sensitive, this body lands in your logs and in the client's console.
Pattern 2: Password Confirmation
Structurally identical, but the input echo now matters a great deal.
class Signup(BaseModel):
email: str
password: str
password_confirm: str
@model_validator(mode="after")
def passwords_match(self) -> "Signup":
if self.password != self.password_confirm:
raise ValueError("password_confirm does not match password")
return self
Real output:
$ POST /signups/ {"email": "a@b.com", "password": "hunter2!", "password_confirm": "hunter3!"}
422 Unprocessable Entity
{
"detail": [
{
"type": "value_error",
"loc": [
"body"
],
"msg": "Value error, password_confirm does not match password",
"input": {
"email": "a@b.com",
"password": "hunter2!",
"password_confirm": "hunter3!"
},
"ctx": {
"error": {}
}
}
]
}
Both passwords are in the 422 response verbatim. This is FastAPI's default RequestValidationError handler faithfully reporting what was submitted, and it is the strongest practical argument for installing a global validation error handler that strips input and ctx before the body leaves the process. Use SecretStr for the fields as well — it keeps them out of tracebacks and repr — but note that the raw input echo above happens before any of that helps, since it is the pre-validation payload.
Keep password_confirm on the request model only. It is a property of the form submission, not of a user, and it should not exist on any model that reaches persistence.
Pattern 3: Conditional Requirement
A field that is optional in general and mandatory in one case. Declare it optional, then enforce the condition.
class Shipment(BaseModel):
"""Conditional requirement: one field is only mandatory for a particular mode."""
method: Literal["pickup", "delivery"]
address: str | None = None
@model_validator(mode="after")
def address_required_for_delivery(self) -> "Shipment":
if self.method == "delivery" and not self.address:
raise ValueError("address is required when method is 'delivery'")
return self
Real output:
$ POST /shipments/ {"method": "delivery"}
422 Unprocessable Entity
{
"detail": [
{
"type": "value_error",
"loc": [
"body"
],
"msg": "Value error, address is required when method is 'delivery'",
"input": {
"method": "delivery"
},
"ctx": {
"error": {}
}
}
]
}
$ POST /shipments/ {"method": "pickup"}
200 OK
{
"method": "pickup",
"address": null
}
The rule works, but it is invisible to the schema: OpenAPI advertises address as optional and nothing communicates the conditional requirement. A generated client will happily construct the invalid request. If the two variants differ by more than one field, a discriminated union on method models the reality far better and puts the requirement in the schema where clients can see it — that is a JSON Schema customization concern.
The Anti-Pattern: field_validator with info.data
The same date-range rule written as a field_validator, with the fields declared in an order that breaks it:
class FieldValidatorAttempt(BaseModel):
"""Trying the same rule with field_validator, which only sees fields defined ABOVE it."""
ends_on: date
starts_on: date
@field_validator("ends_on")
@classmethod
def end_after_start(cls, value: date, info: ValidationInfo) -> date:
# starts_on is declared later, so info.data never contains it here.
start = info.data.get("starts_on")
if start is not None and value <= start:
raise ValueError("ends_on must be later than starts_on")
return value
Real output, with the same invalid payload that produced a 422 above:
$ POST /field-validator-attempt/ {"starts_on": "2026-08-10", "ends_on": "2026-08-01"}
200 OK
{
"ends_on": "2026-08-01",
"starts_on": "2026-08-10"
}
Accepted. starts_on is declared second, so it is not in info.data when the validator for ends_on runs, start is None, and the guard skips the comparison. No warning, no error, no log line — a validation rule that exists in the source and does nothing at runtime.
Reordering the two field declarations would make this particular model work, which is exactly why it is dangerous. The rule's correctness depends on the order two attributes happen to appear in, so a later refactor that sorts fields alphabetically silently disables it. Reviewers do not catch this. Use model_validator and the question never arises.
Edge Cases and Gotchas
- Raise, do not return
False. Amodel_validatorthat returns a bool instead ofselfreplaces the model with that bool. Alwaysraise ValueError(...)andreturn self. - One error at a time. A
model_validatorstops at its first raise, so a form with three broken cross-field rules reports one. If clients need all of them, collect the failures in a list and raise once with a joined message, or raise aPydanticCustomErrorcarrying structured context. mode="after"runs on every construction. That includes rehydrating from cache or the database. A rule that was valid when the row was written but is not valid now will make loading fail. Rules reflecting current policy belong in the service layer.- Mutation in
afteris not re-validated. Normalizingself.address = self.address.strip()inside the validator skips field validation for the new value unlessvalidate_assignmentis set. - Do not put I/O here. Checking that an email is unique needs the database, and validators are synchronous and pure. That belongs behind dependency injection in the service layer.
Verification
Assert the rejection and, just as importantly, assert that the rule fires at all — the field_validator trap above passes a "does it accept valid input" test perfectly:
import pytest
from pydantic import ValidationError
def test_range_rejects_reversed_dates():
with pytest.raises(ValidationError) as exc_info:
DateRange(starts_on=date(2026, 8, 10), ends_on=date(2026, 8, 1))
assert exc_info.value.error_count() == 1
def test_address_required_only_for_delivery():
assert Shipment(method="pickup").address is None
with pytest.raises(ValidationError):
Shipment(method="delivery")
At the HTTP level, assert on the loc too. If your client depends on loc[-1] being a field name, a test that pins loc == ["body"] documents the contract and fails loudly if someone converts the rule to a field validator.
Trade-offs and When Not To
Cross-field rules in the model are cheap, run everywhere the model is constructed, and are impossible to forget. That last property is the trade-off: a rule that is really an endpoint policy — "an admin may submit a backdated range" — becomes a rule the admin path cannot escape. Split the models when the policy differs, rather than threading a flag through the validator.
The loc: ["body"] shape is the other real cost. Frontends that render errors inline next to inputs need a mapping layer, and every cross-field rule you add is another entry in it. If your API's error contract is field-keyed, consider raising a PydanticCustomError with the target field in its context and translating it in a global handler, so the mapping lives in one place instead of being reinvented per client.
FAQ
Why can't field_validator see other fields?
It can see some of them, through info.data, but only the fields that were declared and validated before it. Fields declared later in the class are not in info.data yet, and fields that failed their own validation are missing entirely, so the rule silently does not run instead of failing.
What loc does a cross-field validation error have in the 422 body?
For a request body it is ["body"] with type value_error, because the failure belongs to the model rather than to a single field. Clients that key error messages by field name need a translation step or a custom exception handler.
How do I attach a cross-field error to a specific field in the response?
Raise a PydanticCustomError from the model validator and add a custom exception handler that maps it onto a field, or reshape the 422 in a global handler. Pydantic itself has no way to say a model-level failure belongs to one field.
Should password confirmation be validated in the Pydantic model? It is a reasonable place because the rule is purely a property of the payload and needs no I/O. Keep the two fields on the request model only, never on the stored model, so the confirmation never reaches the database layer.
How do I make a field required only when another field has a particular value?
Declare it optional with a None default and enforce the requirement in a model_validator with mode="after". The field stays optional in the JSON Schema, so document the conditional rule in the field description or with a discriminated union if the variants are genuinely distinct.
Related Reading
- Up to the topic: custom validators and field constraints.
- The mode semantics these patterns rely on are detailed in before, after and wrap validators, and packaging rules for reuse is covered in creating reusable custom validators.
- If you are arriving from v1, @root_validator to @model_validator covers the decorator change these patterns depend on.
- To reshape the 422 bodies shown above, see customising validation error responses.