Custom Validators and Field Constraints in Pydantic
Most validation questions are not really about how to write a validator. They are about where the rule belongs — and Pydantic offers four places to put one, each with different visibility to clients, different error shapes, and different things it is allowed to do.
This is the validation core of Advanced Pydantic Validation and Serialization. This guide gives you the ladder for choosing a tier and the execution model you need to predict what happens when several rules fail at once. The individual techniques — the mode argument, reuse via Annotated, cross-field shapes, and the async problem — each have their own page beneath this one.
Prerequisites
You need Pydantic v2 idioms — field_validator and model_validator rather than their v1 predecessors. If you are still on the old decorators, start with the Pydantic V2 Migration Guide, because the v1 decorators bound their arguments differently and a rule carried across unchanged can quietly stop working.
You should also know how FastAPI turns a ValidationError into a 422 response body, since most of this guide is about controlling what ends up in that body. And it helps to have read how dependency injection works, because tier four of the ladder is a dependency, not a validator.
Core Mechanics: Validation Is a Compiled Tree, Not a Chain
The mental model that makes Pydantic v2's behaviour predictable is that validation is not a sequence of functions called in order. When a model class is defined, Pydantic builds a core schema — a nested description of how to validate this model — and compiles it. A model's core schema contains a field schema per field; each field schema contains the type's validator, any constraints, and any of your functions attached at their declared positions.
Two consequences follow, and between them they explain most surprising behaviour.
Constraints are not the same kind of thing as validators. Field(gt=0) does not become a Python function. It compiles into the field's schema as a property of the type itself, which is why it can also be rendered into JSON Schema. Your field_validator becomes a call-out from the compiled schema into the interpreter, and the schema generator has no way to look inside it.
The model validator sits outside every field schema. A model_validator(mode="after") receives a constructed instance. That is a structural fact, not a convention: if any field failed, there is no instance, so there is nothing to call it with.
Watching the tree short-circuit
The clearest way to internalise this is to instrument every tier and see which ones are reached. The example below records each stage as it fires, then reports the trace alongside the errors:
class Transfer(BaseModel):
"""Three stages that each record whether they were reached."""
amount: Annotated[int, Field(gt=0)]
source: str
target: str
@field_validator("amount")
@classmethod
def amount_after(cls, value: int) -> int:
TRACE.append(f"field_validator(amount) saw {value!r}")
return value
@field_validator("source", "target")
@classmethod
def account_after(cls, value: str) -> str:
TRACE.append(f"field_validator(account) saw {value!r}")
if not value.startswith("AC"):
raise ValueError("account numbers start with AC")
return value
@model_validator(mode="after")
def distinct_accounts(self) -> "Transfer":
TRACE.append("model_validator reached")
if self.source == self.target:
raise ValueError("source and target must differ")
return self
The recorded output for four inputs — this is the real transcript:
$ POST /transfers/trace {"amount": 100, "source": "AC1", "target": "AC2"}
200 OK
{
"outcome": "valid",
"errors": [],
"trace": [
"field_validator(amount) saw 100",
"field_validator(account) saw 'AC1'",
"field_validator(account) saw 'AC2'",
"model_validator reached"
]
}
$ POST /transfers/trace {"amount": -5, "source": "AC1", "target": "AC2"}
200 OK
{
"outcome": "rejected (1 error(s))",
"errors": [
"('amount',): greater_than"
],
"trace": [
"field_validator(account) saw 'AC1'",
"field_validator(account) saw 'AC2'"
]
}
$ POST /transfers/trace {"amount": 100, "source": "XX1", "target": "AC2"}
200 OK
{
"outcome": "rejected (1 error(s))",
"errors": [
"('source',): value_error"
],
"trace": [
"field_validator(amount) saw 100",
"field_validator(account) saw 'XX1'",
"field_validator(account) saw 'AC2'"
]
}
$ POST /transfers/trace {"amount": 100, "source": "AC1", "target": "AC1"}
200 OK
{
"outcome": "rejected (1 error(s))",
"errors": [
"(): value_error"
],
"trace": [
"field_validator(amount) saw 100",
"field_validator(account) saw 'AC1'",
"field_validator(account) saw 'AC1'",
"model_validator reached"
]
}
Three things in that trace are worth reading carefully.
In the second case the constraint on amount rejected the value, and field_validator(amount) does not appear in the trace — a function attached after a constraint never sees a value the constraint refused. But the other fields were still validated. Field validation is not abandoned at the first failure, because the whole point of the 422 body is to report every problem in one response.
In the third case a field_validator raised, and again the remaining fields were still processed. What is missing from both the second and third traces is model_validator reached. One failed field is enough to skip the model tier entirely.
In the fourth case every field was individually fine, so the model validator ran — and its error loc is empty. That is the cost of tier three, and it is visible in the response FastAPI actually sends:
$ POST /transfers/ {"amount": 100, "source": "AC1", "target": "AC1"}
422 Unprocessable Entity
{
"detail": [
{
"type": "value_error",
"loc": [
"body"
],
"msg": "Value error, source and target must differ",
"input": {
"amount": 100,
"source": "AC1",
"target": "AC1"
},
"ctx": {
"error": {}
}
}
]
}
loc is ["body"] with no field after it, and input echoes the entire submitted payload back to the caller. Both matter in production. A front end that highlights the offending input by reading the end of loc has nothing to work with, and any sensitive field in the request is now in the error response. If your API returns validation errors to browsers, this is an argument for a global handler that reshapes them before they leave the process.
Production Implementation: Working Down the Ladder
Tier one — prefer a constraint whenever one exists
The first tier is not just the cheapest, it is the only one your clients can see. Compare the same rule written both ways:
class DeclaredDiscount(BaseModel):
"""The rule lives in the type."""
percent: Annotated[int, Field(ge=0, le=100)]
class ImperativeDiscount(BaseModel):
"""The identical rule, expressed as code."""
percent: int
@field_validator("percent")
@classmethod
def in_range(cls, value: int) -> int:
if not 0 <= value <= 100:
raise ValueError("percent must be between 0 and 100")
return value
Identical behaviour, and two different contracts. The recorded output:
$ GET /schemas
200 OK
{
"declared": {
"maximum": 100,
"minimum": 0,
"title": "Percent",
"type": "integer"
},
"imperative": {
"title": "Percent",
"type": "integer"
}
}
$ POST /declared/ {"percent": 150}
422 Unprocessable Entity
{
"detail": [
{
"type": "less_than_equal",
"loc": [
"body",
"percent"
],
"msg": "Input should be less than or equal to 100",
"input": 150,
"ctx": {
"le": 100
}
}
]
}
$ POST /imperative/ {"percent": 150}
422 Unprocessable Entity
{
"detail": [
{
"type": "value_error",
"loc": [
"body",
"percent"
],
"msg": "Value error, percent must be between 0 and 100",
"input": 150,
"ctx": {
"error": {}
}
}
]
}
The schema difference is the obvious one: the declared version publishes minimum and maximum, and the imperative version publishes an unbounded integer, so a generated client will happily send 150 and a reader of your docs has no way to learn the rule. What is easy to overlook is the error difference. The constraint produces a machine-readable type of less_than_equal with the bound available in ctx, so a client can localise the message or render a slider. The function produces value_error and an English sentence, which is all any consumer will ever get. The consequences of that schema gap for reusable types are explored in Creating Reusable Custom Validators in Pydantic.
Tier two — a field validator, and the mode question
When no constraint fits, a field_validator handles rules about one field. The one decision it forces on you is mode, which determines whether your function runs before or after the type has been parsed. Normalisation — trimming, case-folding, accepting a legacy format — must run before, because it needs the raw value. Assertions about the parsed value run after, and are guaranteed the declared type, so they never need to defend against surprises.
There is a third mode, wrap, which is the only one that can intercept a failure and substitute something. The three modes' relative firing order is not guessable, and it has been settled by execution trace rather than by argument in Before, After and Wrap Validators in Pydantic v2.
Tier three — model validators, for rules with more than one operand
A rule like "check-out must be after check-in" is not about either date. It is about the pair, and no single-field validator can see the pair reliably. The after mode gives you a fully constructed instance with every field present and typed:
@model_validator(mode="after")
def check_out_after_check_in(self) -> "Booking":
if self.check_out <= self.check_in:
raise ValueError("check_out must be after check_in")
return self
The trap here is the tempting alternative — reaching for the partial data available inside a field validator to peek at a sibling. It appears to work and then fails in a way no test notices. Cross-Field Validation Patterns in Pydantic v2 demonstrates the failure and catalogues the three shapes cross-field rules usually take.
Tier four — when it is not validation at all
Some rules look like validation and are not. "This email must not already be registered" is not a statement about the shape of the request; it is a question about the state of your database. It cannot be answered without I/O, and the validation core cannot perform I/O — it is synchronous compiled code with no access to an event loop.
What makes this worth stating explicitly is that Pydantic does not stop you from trying. An async def validator is accepted at class definition, runs without error, and stores something useless in the field. Pydantic v2 Async Custom Validator: What to Do Instead shows exactly what ends up in the model and gives the dependency-based replacement. The status code is part of the reason to move the rule: a shape problem is a 422, but a uniqueness conflict is a 409, and only tier four lets you say so.
Performance Notes
Validators are invoked once per constructed instance, which on a busy endpoint with a list-valued body means once per element, not once per request. That multiplier is the reason the purity rule matters: a function that is merely slow rather than incorrect will still show up in your latency profile at the top of the list.
The tiers themselves are not equally cheap. Constraints run inside the compiled core with no interpreter round-trip. Every field_validator and model_validator is a call back into Python, and that transition is the cost you are paying for expressiveness. This is another reason the ladder is ordered the way it is — tier one is faster as well as clearer, so the preference costs you nothing.
What genuinely matters more than any of this is not running validation twice on the same data. Once a request body has been validated at the edge, passing the resulting model onward rather than re-parsing it is the single largest saving available, and it is covered in Performance Optimization for Models.
Testing Strategy
A validator that never rejects anything is indistinguishable from no validator at all, and it is depressingly easy to ship one. Every rule needs a test that feeds it something invalid.
import pytest
from pydantic import ValidationError
def test_transfer_rejects_same_account():
with pytest.raises(ValidationError) as exc_info:
Transfer(amount=100, source="AC1", target="AC1")
assert exc_info.value.error_count() == 1
Two refinements make these tests much more useful. First, assert on the error type, not the message — messages are prose and will be reworded, while greater_than and value_error are part of your API. Second, for rules attached to a reusable type rather than a model, test the type directly with TypeAdapter instead of inventing a throwaway model to hang it on.
For endpoint-level tests, TestClient exercises the whole path including FastAPI's error envelope, which is what you want when you are asserting on the loc shape that your front end depends on. Use dependency_overrides to substitute the tier-four rules so a uniqueness check does not need a live database — see Overriding Dependencies in Tests.
Failure Modes and Diagnosis
A validator silently does nothing. Symptom: invalid data is accepted, and the rule looks correct on the page. Diagnosis: check the return statement first — a validator that falls off the end returns None and nulls the field. Then check whether it is an async def, which stores a coroutine and enforces nothing.
A cross-field rule intermittently does not fire. Diagnosis: it is reading sibling data inside a field validator. Whether the sibling is available depends on field declaration order, so reordering fields or renaming them can switch the rule off. Move it to a model validator.
A 500 instead of a 422. Diagnosis: the validator raised something other than ValueError or AssertionError. Only those two are converted. A TypeError from calling a string method on a value that arrived as None is the usual culprit, and it happens in before mode where the raw input has not been type-checked yet.
The model validator never runs. Diagnosis: some field failed. Look at the rest of the 422 body — the field error is there, and the model rule was skipped because there was no instance to hand it.
Validation is slower than expected under load. Diagnosis: count how many instances a single request constructs. A list body constructs one model per element, and each one runs every validator you declared.
Sensitive data appears in error responses. Diagnosis: model-level errors echo the whole submitted body in input, as shown above. Strip input and ctx in a global exception handler.
Choosing a Tier
| Field constraint | field_validator | model_validator | Dependency | |
|---|---|---|---|---|
| Expresses | bounds, length, pattern | any rule about one field | rules relating fields | rules about system state |
| In the JSON Schema | yes | no | no | no |
Error loc | names the field | names the field | ["body"] only | you choose |
Machine-readable error type | yes, with ctx | value_error | value_error | you choose |
| Can read other fields | no | not reliably | yes | yes |
| May perform I/O | no | no | no | yes |
| Response status | 422 | 422 | 422 | any |
Read it top to bottom and take the first row that fits. The rows are ordered by how much the client learns from your choice, and the temptation is always to skip to the tier that is easiest to write rather than the one that communicates most.
FAQ
How do I decide between a Field constraint, a field_validator and a model_validator?
Work down the list and stop at the first tier that can express the rule. A Field constraint if one exists, because it is the only tier that also documents itself to clients. A field_validator when the rule concerns one field and needs logic. A model_validator when the rule relates two or more fields. Anything requiring I/O is not a validation tier at all and belongs in a dependency.
If one field fails validation, do the other fields still get validated? Yes. Field validation is per field, and every field is attempted so that a single response can report all the problems at once. What does not run is the model validator: it needs a fully constructed model, so if any field failed there is no instance to hand it and it is skipped entirely.
Why does my cross-field error have no field name in it?
A model_validator failure is attributed to the model, not to a field, so FastAPI reports loc as body with nothing after it. Clients that map errors onto form fields by reading the last element of loc will have nothing to attach it to. If the error is really about one field, raise it from that field's validator instead.
Does a custom validator show up in the OpenAPI documentation?
No. Field constraints compile into the JSON Schema as keywords like maximum and minLength, but a Python function is opaque to the schema generator. A rule enforced only in a validator is invisible to anyone reading your docs or generating a client, which is the main reason to prefer a constraint whenever one can express the rule.
Can a validator be async or query the database? No. The validation core is synchronous compiled code with no event loop, and it will neither await a coroutine nor complain about one. An async validator silently stores an un-awaited coroutine object in the field. Rules that need I/O belong in a FastAPI dependency, where awaiting is legal and the failure can carry an appropriate status code.
Related Reading
- Up to the section: Advanced Pydantic Validation and Serialization for how validation connects to schema generation and serialization.
- The
modeargument, settled by execution trace: Before, After and Wrap Validators in Pydantic v2. - Defining a rule once on a type and reusing it: Creating Reusable Custom Validators in Pydantic.
- Rules with more than one operand, and the trap that disables them: Cross-Field Validation Patterns in Pydantic v2.
- Why an async validator enforces nothing, and where the rule goes instead: Pydantic v2 Async Custom Validator: What to Do Instead.
- How the rules you write surface to clients: JSON Schema Customization.