Creating Reusable Custom Validators in Pydantic

Key takeaways:

  • Define validation logic as a pure function, then attach it to a type with AfterValidator or BeforeValidator.
  • Alias the Annotated type 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. BeforeValidator runs before coercion and AfterValidator runs after; place normalization before and assertions after.
  • Type stability. A BeforeValidator may receive a non-string; guard with isinstance before calling string methods.
  • Schema visibility. Field constraints appear in the JSON Schema, but custom functions do not describe themselves; add a Field(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)