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.

Four tiers for placing a validation ruleA ladder of four tiers. A Field constraint reaches the JSON Schema and reports a typed error. A field_validator handles single-field logic. A model_validator handles rules spanning fields and reports against the body. A dependency handles rules needing input or output, outside the model entirely.Stop at the first tier that can express the rule1. Field constraintgt, max_length, pattern — compiled, and visible to clientsin the JSON Schematyped error + ctx2. field_validatorone field, needs logic — sees only its own valueinvisible in schemaerror names the field3. model_validatorrelates two or more fields — needs a built modelinvisible in schemaerror names no field4. dependencyneeds I/O or app state — outside the modelyou choose the statusmay awaitDescending the ladder buys expressiveness and spends client-facing clarity.
Each step down the ladder can express more, and tells the client less. Tier one is the only one that appears in your OpenAPI document; tier four is the only one allowed to touch the network.

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 constraintfield_validatormodel_validatorDependency
Expressesbounds, length, patternany rule about one fieldrules relating fieldsrules about system state
In the JSON Schemayesnonono
Error locnames the fieldnames the field["body"] onlyyou choose
Machine-readable error typeyes, with ctxvalue_errorvalue_erroryou choose
Can read other fieldsnonot reliablyyesyes
May perform I/Onononoyes
Response status422422422any

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.