Creating Reusable Custom Validators in Pydantic
Key takeaways:
- Define validation logic as a pure function, then attach it to a type with
AfterValidatororBeforeValidator. - Alias the
Annotatedtype so the rule is defined once and reused everywhere. - Use a factory function for parameterized rules that vary by argument.
- Stack multiple validators and constraints on one annotated type; they run in order.
- Keep every reusable validator pure — no I/O — so it stays fast and predictable.
This is the DRY, hands-on companion to Custom Validators and Field Constraints. Read that page for the execution-order model behind these techniques.
The Problem This Solves
The same rules recur across a codebase: a slug must be lowercase and hyphenated, a percentage must sit between 0 and 100, a phone number must match a format. Re-implementing these as field_validator methods on every model duplicates logic and lets the rules drift apart. Attaching them to reusable types defines each rule once.
Prerequisites
- Pydantic v2.
- Python 3.11+ for
Annotated.
Step-by-Step Implementation
1. Write a pure validation function
def to_slug(value: str) -> str:
# Pure: deterministic, no side effects, raises on invalid input.
slug = value.strip().lower().replace(" ", "-")
if not slug.replace("-", "").isalnum():
raise ValueError("slug may contain only letters, numbers, and hyphens")
return slug
2. Attach it to a reusable type
from typing import Annotated
from pydantic import BeforeValidator
# Defined once; any field of this type is normalized and validated.
Slug = Annotated[str, BeforeValidator(to_slug)]
3. Use it across models
from pydantic import BaseModel
class Article(BaseModel):
slug: Slug # Inherits normalization + validation automatically.
class Category(BaseModel):
slug: Slug # Same rule, zero duplication.
4. Parameterize with a factory
from pydantic import AfterValidator
def bounded(low: int, high: int):
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)] # Configured instance of the rule.
5. Stack validators and constraints
from pydantic import Field
# Runs in order: normalize (before) → bound (Field) → assert (after).
TrimmedName = Annotated[str, BeforeValidator(str.strip), Field(min_length=1, max_length=80)]
Edge Cases and Gotchas
- Order matters.
BeforeValidatorruns before coercion andAfterValidatorruns after; place normalization before and assertions after. - Type stability. A
BeforeValidatormay receive a non-string; guard withisinstancebefore calling string methods. - Schema visibility.
Fieldconstraints appear in the JSON Schema, but custom functions do not describe themselves; add aField(description=...)so docs stay informative.
Verification
import pytest
from pydantic import BaseModel, ValidationError
class M(BaseModel):
slug: Slug
pct: Percentage
def test_reusable_validators():
assert M(slug="Hello World", pct=50).slug == "hello-world"
with pytest.raises(ValidationError):
M(slug="bad/slug", pct=50)
with pytest.raises(ValidationError):
M(slug="ok", pct=200)
Related Reading
- Up to the topic: Custom Validators and Field Constraints.
- Related patterns: Type Hinting and IDE Integration for how Annotated types improve tooling, and JSON Schema Customization for documenting them.