Custom Types
Creating reusable validation types with Annotated, AfterValidator, and BeforeValidator.
I keep writing the same validator on multiple models — like stripping whitespace and lowercasing email fields. Is there a way to create a reusable "validated email" type that I can use anywhere?
Yes — with Annotated and validator functions. You create a custom type once and use it in any model:
from typing import Annotated
from pydantic import AfterValidator
def normalize_email(v: str) -> str:
v = v.strip().lower()
if "@" not in v:
raise ValueError("must contain @")
return v
Email = Annotated[str, AfterValidator(normalize_email)]
Now Email is a reusable type. Use it anywhere you'd use str:
from pydantic import BaseModel
class User(BaseModel):
name: str
email: Email
class Contact(BaseModel):
primary_email: Email
backup_email: Email
user = User(name="Alice", email=" ALICE@Example.COM ")
print(user.email) # "alice@example.com"
One definition, used in multiple models. The validation logic lives with the type, not scattered across model validators.
What's the difference between AfterValidator and BeforeValidator?
AfterValidator runs after Pydantic's built-in type coercion — you receive a value that's already the declared type. BeforeValidator runs before coercion — you receive the raw input:
from pydantic import BeforeValidator
def parse_comma_list(v):
if isinstance(v, str):
return [s.strip() for s in v.split(",")]
return v
CommaList = Annotated[list[str], BeforeValidator(parse_comma_list)]
class Tags(BaseModel):
items: CommaList
# Pass a comma-separated string — BeforeValidator converts it to a list
t = Tags(items="python, pydantic, validation")
print(t.items) # ['python', 'pydantic', 'validation']
# Or pass a list directly — it passes through
t2 = Tags(items=["a", "b"])
print(t2.items) # ['a', 'b']
Can I stack multiple validators on the same type?
Yes. They run in order — each one transforms the value and passes it to the next:
def strip_whitespace(v: str) -> str:
return v.strip()
def must_not_be_empty(v: str) -> str:
if not v:
raise ValueError("must not be empty")
return v
def to_lowercase(v: str) -> str:
return v.lower()
CleanString = Annotated[str, AfterValidator(strip_whitespace), AfterValidator(must_not_be_empty), AfterValidator(to_lowercase)]
Can I add Field constraints to an Annotated type too?
Yes. Annotated can hold multiple metadata items — validators, Field constraints, and more:
from pydantic import Field
PositiveInt = Annotated[int, Field(gt=0, description="A positive integer")]
ShortString = Annotated[str, Field(min_length=1, max_length=50), AfterValidator(lambda v: v.strip())]
class Config(BaseModel):
retries: PositiveInt
name: ShortString
Custom types with Annotated are the Pydantic way to build a library of reusable, self-documenting validation rules. Define them once in a types.py module and import them across your project.
Practice your skills
Sign up to write and run code in this lesson.
Custom Types
Creating reusable validation types with Annotated, AfterValidator, and BeforeValidator.
I keep writing the same validator on multiple models — like stripping whitespace and lowercasing email fields. Is there a way to create a reusable "validated email" type that I can use anywhere?
Yes — with Annotated and validator functions. You create a custom type once and use it in any model:
from typing import Annotated
from pydantic import AfterValidator
def normalize_email(v: str) -> str:
v = v.strip().lower()
if "@" not in v:
raise ValueError("must contain @")
return v
Email = Annotated[str, AfterValidator(normalize_email)]
Now Email is a reusable type. Use it anywhere you'd use str:
from pydantic import BaseModel
class User(BaseModel):
name: str
email: Email
class Contact(BaseModel):
primary_email: Email
backup_email: Email
user = User(name="Alice", email=" ALICE@Example.COM ")
print(user.email) # "alice@example.com"
One definition, used in multiple models. The validation logic lives with the type, not scattered across model validators.
What's the difference between AfterValidator and BeforeValidator?
AfterValidator runs after Pydantic's built-in type coercion — you receive a value that's already the declared type. BeforeValidator runs before coercion — you receive the raw input:
from pydantic import BeforeValidator
def parse_comma_list(v):
if isinstance(v, str):
return [s.strip() for s in v.split(",")]
return v
CommaList = Annotated[list[str], BeforeValidator(parse_comma_list)]
class Tags(BaseModel):
items: CommaList
# Pass a comma-separated string — BeforeValidator converts it to a list
t = Tags(items="python, pydantic, validation")
print(t.items) # ['python', 'pydantic', 'validation']
# Or pass a list directly — it passes through
t2 = Tags(items=["a", "b"])
print(t2.items) # ['a', 'b']
Can I stack multiple validators on the same type?
Yes. They run in order — each one transforms the value and passes it to the next:
def strip_whitespace(v: str) -> str:
return v.strip()
def must_not_be_empty(v: str) -> str:
if not v:
raise ValueError("must not be empty")
return v
def to_lowercase(v: str) -> str:
return v.lower()
CleanString = Annotated[str, AfterValidator(strip_whitespace), AfterValidator(must_not_be_empty), AfterValidator(to_lowercase)]
Can I add Field constraints to an Annotated type too?
Yes. Annotated can hold multiple metadata items — validators, Field constraints, and more:
from pydantic import Field
PositiveInt = Annotated[int, Field(gt=0, description="A positive integer")]
ShortString = Annotated[str, Field(min_length=1, max_length=50), AfterValidator(lambda v: v.strip())]
class Config(BaseModel):
retries: PositiveInt
name: ShortString
Custom types with Annotated are the Pydantic way to build a library of reusable, self-documenting validation rules. Define them once in a types.py module and import them across your project.
Practice your skills
Sign up to write and run code in this lesson.