Creating Reusable Custom Validators in Pydantic
Key takeaways:
- Attach a rule to a type with
Annotated, and every model using that type inherits it. - Stacked validators run left to right:
BeforeValidator→Fieldconstraint →AfterValidator. - The
Fieldconstraint measures the value after theBeforeValidatortransformed it. - A factory that closes over its arguments gives you parameterized reusable rules.
- Custom function validators contribute nothing to the JSON Schema — only
Fielddoes.
This is the reuse-focused companion to custom validators and field constraints, which covers the execution model in general; here the concern is defining a rule once and spending it everywhere.
The Problem This Solves
The same rules recur across a codebase. A slug is lowercase and hyphenated. A percentage sits between 0 and 100. A currency code is three uppercase letters. Written as field_validator methods, each of these gets re-implemented on every model that needs it, and the copies drift: one strips whitespace, one does not; one rejects empty strings, one returns them.
The deeper problem is that the rule ends up belonging to the model rather than to the concept. Article.slug and Category.slug are the same kind of thing, and nothing in the code says so.
Why It Happens
Pydantic v2 builds a validation pipeline per field by walking the field's type annotation. Annotated[str, ...] gives it a base type plus a tuple of metadata, and it inspects each metadata item to decide what to append to that field's chain.
This is the key mechanism: the rule lives in the annotation, not in the class body. field_validator works by registering a function against a field name on one model — inherently model-scoped. Metadata inside Annotated travels with the type object itself, so aliasing Slug = Annotated[str, BeforeValidator(to_slug)] and annotating a field with Slug gets the whole chain, in any model, in any module, with no import of the model needed.
The chain is assembled in the order the metadata appears, wrapping outward from the base type. That ordering is usually described rather than demonstrated, so below it is measured.
Prerequisites
- Pydantic 2.13.4 and Python 3.12 — the environment every transcript below came from.
- Familiarity with the
before/afterdistinction from before, after and wrap validators.
Building the Reusable Type
A pure function, wrapped in metadata, aliased to a name:
"""Reusable Annotated validators: real execution order, factories, 422 shape, and JSON schema."""
from typing import Annotated
from pydantic import AfterValidator, BaseModel, BeforeValidator, Field
def to_slug(value: str) -> str:
slug = value.strip().lower().replace(" ", "-")
if not slug.replace("-", "").isalnum():
raise ValueError("slug may contain only letters, numbers, and hyphens")
return slug
Slug = Annotated[str, BeforeValidator(to_slug)]
def bounded(low: int, high: int) -> AfterValidator:
"""A factory: closes over the bounds and returns a configured validator."""
def _check(value: int) -> int:
if not low <= value <= high:
raise ValueError(f"must be between {low} and {high}")
return value
return AfterValidator(_check)
Percentage = Annotated[int, bounded(0, 100)]
class Article(BaseModel):
slug: Slug
pct: Percentage
class Category(BaseModel):
"""Same Slug type, same rule — declared nowhere in this class."""
slug: Slug
Category contains no validation code at all, yet enforces the identical rule. Real output from _verify/output/up-pyd-reusable-validators.txt:
$ POST /article {"slug": "Hello World", "pct": 50}
200 OK
{
"slug": "hello-world",
"pct": 50
}
$ POST /category {"slug": " Deep Dives "}
200 OK
{
"slug": "deep-dives"
}
Both models normalised identically. When both rules fail, both errors are reported, with loc pointing at each field:
$ POST /article {"slug": "bad/slug", "pct": 200}
422 Unprocessable Entity
{
"detail": [
{
"type": "value_error",
"loc": [
"body",
"slug"
],
"msg": "Value error, slug may contain only letters, numbers, and hyphens",
"input": "bad/slug",
"ctx": {
"error": {}
}
},
{
"type": "value_error",
"loc": [
"body",
"pct"
],
"msg": "Value error, must be between 0 and 100",
"input": 200,
"ctx": {
"error": {}
}
}
]
}
The factory-built validator produces a normal field error with the correct loc, which is worth confirming rather than assuming — a closure-based validator is not a second-class citizen in the error reporting.
The Execution Order, Measured
Stack all three kinds of metadata on one type, with the functions recording when they fire:
Probe = Annotated[
str,
BeforeValidator(traced("before", lambda v: v.strip() if isinstance(v, str) else v)),
Field(min_length=3),
AfterValidator(traced("after", lambda v: v.upper())),
]
$ GET /order
200 OK
{
"input": "' hello '",
"trace": [
"before",
"after"
],
"result": "HELLO"
}
Left to right, as written. The more interesting question is where the Field constraint sits, which the failure case answers:
$ GET /order-constraint-failure
200 OK
{
"outcome": "rejected",
"trace": [
"before"
],
"errors": [
{
"type": "too_short",
"msg": "Value should have at least 3 items after validation, not 1"
}
]
}
The input was " a " — five characters. The error says 1. So the BeforeValidator stripped it first, and min_length=3 measured the result. The trace confirms the ordering from the other side: before ran, after did not, because the constraint rejected the value in between them.
This matters whenever normalisation changes length. Field(min_length=1) on a whitespace-stripping type rejects " ", which is almost always what you want and is not what you get if you assume the constraint sees the raw input. Pydantic's own wording — "at least 3 items after validation" — is telling you exactly this.
What the Schema Loses
Reusable types have one real cost, and it is invisible until someone consumes your OpenAPI document:
$ GET /schema
200 OK
{
"Article": {
"properties": {
"slug": {
"title": "Slug",
"type": "string"
},
"pct": {
"title": "Pct",
"type": "integer"
}
},
"required": [
"slug",
"pct"
],
"title": "Article",
"type": "object"
},
"Probed": {
"properties": {
"name": {
"minLength": 3,
"title": "Name",
"type": "string"
}
},
"required": [
"name"
],
"title": "Probed",
"type": "object"
}
}
slug is published as "type": "string". Nothing about hyphens, lowercase, or the allowed character set. pct is "type": "integer" with no bounds — the 0..100 rule is completely invisible. Meanwhile Probed.name carries "minLength": 3, because that one came from Field.
The rule is simple: Field constraints emit JSON Schema keywords, arbitrary Python functions cannot. Pydantic has no way to translate to_slug into a pattern. So a client generated from this document will send "Hello World", get a 422, and find nothing in the docs explaining why.
Two mitigations, and you generally want both. Prefer a declarative constraint where one exists, since Field(pattern=...) is both enforced and published. Where the rule genuinely needs a function, document it explicitly on the type:
Slug = Annotated[
str,
BeforeValidator(to_slug),
Field(
description="Lowercase, hyphen-separated. Spaces are converted; other punctuation is rejected.",
json_schema_extra={"example": "deep-dives"},
),
]
The description travels with the type to every model that uses it, which is the same reuse property working in your favour. More on shaping this output in customizing OpenAPI schema generation and examples in the OpenAPI schema.
Verification
Test the type directly rather than through a model. The rule lives on the type, so that is the unit:
import pytest
from pydantic import BaseModel, TypeAdapter, ValidationError
slug_adapter = TypeAdapter(Slug)
@pytest.mark.parametrize("raw,expected", [("Hello World", "hello-world"), (" A B ", "a-b")])
def test_slug_normalizes(raw, expected):
assert slug_adapter.validate_python(raw) == expected
def test_slug_rejects_punctuation():
with pytest.raises(ValidationError):
slug_adapter.validate_python("bad/slug")
TypeAdapter is the right tool here — it compiles the same validator chain the model would build, without needing a throwaway model per rule. That is covered in TypeAdapter for non-model types. One test per reusable type then covers every model that uses it, which is the payoff for defining the rule once.
Trade-offs and When Not To
The reuse property cuts both ways. Changing to_slug changes every model using Slug simultaneously, including ones written by other teams and ones that only validate stored data on read. A tightened rule can start rejecting rows already in the database. Version the type rather than mutate it when the rule genuinely changes meaning.
Annotated types are also worse than field_validator at a few things. They cannot read other fields — there is no ValidationInfo.data reaching across to a sibling — so anything relational belongs in cross-field validation patterns. They cannot be async, for the reasons in Pydantic v2 async custom validator. And a deep stack of metadata on one alias becomes genuinely hard to read; past three or four items, a named field_validator on a model is often clearer than a type nobody can decode at a glance.
Finally, do not reach for a custom function when a constraint already exists. Field(pattern=...), Field(gt=..., le=...), and the constrained types Pydantic ships are enforced in Rust rather than Python and — the more important half — they appear in the schema. A hand-written bounded(0, 100) is a worse Field(ge=0, le=100) in every respect except expressiveness. Reserve functions for rules that genuinely cannot be declared.
FAQ
In what order do stacked Annotated validators run?
Left to right as written: BeforeValidator, then any Field constraint, then AfterValidator. A traced run on Pydantic 2.13.4 confirms the before function fires first and the after function last, with the constraint checked in between.
Does a Field constraint see the value before or after a BeforeValidator?
After. A BeforeValidator that strips whitespace runs first, so Field(min_length=3) measures the stripped string. The value " a " fails with too_short and a message saying the length is 1, not 5.
Do custom Annotated validators show up in the OpenAPI schema?
No. A BeforeValidator or AfterValidator contributes nothing to the generated JSON Schema — a slug type with a strict format rule is published as plain "type": "string". Only Field constraints such as min_length emit schema keywords.
How do I write a reusable validator that takes parameters?
Use a factory that closes over the parameters and returns a configured AfterValidator. A bounded(0, 100) call returns a validator for that range, which you then place in Annotated to build a named type such as Percentage.
Should a reusable validator be a type or a field_validator method?
Use an Annotated type when the rule belongs to the concept rather than to one model, since every field of that type inherits it. Use field_validator when the rule is specific to one model or needs to read another field through ValidationInfo.
Related Reading
- Up to the topic: custom validators and field constraints.
- The ordering model in full, including
wrap, is in before, after and wrap validators. - Rules spanning two fields cannot be types; see cross-field validation patterns.
- The same
Annotatedmechanism powers annotated dependencies and reusable types. - To validate a reusable type without a model, see TypeAdapter for non-model types.