Reusable types with Annotated validators

Pydantic · Advanced · 12 min

Validation logic attached to a type can be reused across many models instead of copied into each.

Decorator validators belong to one model. The annotated pattern attaches logic to a type instead, so you define a domain type once and use it everywhere:

Slug = Annotated[str, AfterValidator(check_slug)]

The functional validators mirror the decorator modes:

  • BeforeValidator(fn) runs on raw input before the inner type validates it
  • AfterValidator(fn) runs on the already-validated value
  • WrapValidator(fn) gets (value, handler) and decides when to call the inner validation
  • PlainValidator(fn) replaces validation entirely

Several metadata items in one Annotated apply in order: before validators from right to left, then the inner type, then after validators from left to right. You can mix in Field constraints and serializers such as PlainSerializer(fn, when_used="json").

These types compose with containers: list[Slug] validates every element, and dict[Slug, int] validates keys.

Because the logic is plain functions, you can unit-test it without models, and because the type is a value, it can be imported from a shared types.py module.

For truly custom classes (your own Money class, not just a constrained str), see __get_pydantic_core_schema__ in the internals lesson. For most domain rules, Annotated with functional validators is enough and much simpler.

Example

import re
from typing import Annotated
from pydantic import AfterValidator, BaseModel, BeforeValidator, Field, PlainSerializer

def check_slug(v: str) -> str:
    if not re.fullmatch(r"[a-z0-9]+(-[a-z0-9]+)*", v):
        raise ValueError("not a slug")
    return v

Slug = Annotated[str, BeforeValidator(lambda v: v.strip().lower() if isinstance(v, str) else v),
                 AfterValidator(check_slug)]
Cents = Annotated[int, Field(ge=0), PlainSerializer(lambda c: f"{c / 100:.2f}", when_used="json")]

class Post(BaseModel):
    slug: Slug
    tags: list[Slug] = []
    price: Cents = 0

p = Post(slug="  Hello-World ", tags=["Py", "web-dev"], price=1999)
print(p.slug, p.tags, p.price)     # hello-world ['py', 'web-dev'] 1999
print(p.model_dump_json())
# {"slug":"hello-world","tags":["py","web-dev"],"price":"19.99"}

Remember

  • Annotated[T, AfterValidator(fn)] defines a reusable validated type
  • Before/After/Wrap/Plain validators mirror the decorator modes
  • Annotated types compose inside list, dict and other containers

Watch out for

  • Copying the same @field_validator into many models instead of defining one Annotated type.

Check yourself

  1. In Annotated[str, BeforeValidator(f), AfterValidator(g)], what is the order of execution?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: docs.pydantic.dev/latest/concepts/validators/#field-validators