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 itAfterValidator(fn)runs on the already-validated valueWrapValidator(fn)gets(value, handler)and decides when to call the inner validationPlainValidator(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.