Implementing Custom Validators
Custom validators in pydantic-core allow you to extend the core validation logic with your own Python functions. By using different validator patterns, you can pre-process raw input, refine validated data, or completely take over the validation process.
In this tutorial, you will build a robust "Username" validation system that handles raw input, enforces formatting, and checks against a dynamic blacklist using the four primary validator patterns.
Prerequisites
To follow this tutorial, you need pydantic_core installed. You will primarily interact with the core_schema module to define your validation logic.
from typing import Any
from pydantic_core import SchemaValidator, core_schema, ValidationError
Step 1: Pre-processing with Before Validators
A "Before" validator runs before the inner schema logic. It is ideal for coercing raw input into a format the inner schema expects. You will use WithInfoValidatorFunctionSchema (via the with_info_before_validator_function factory) to ensure that even if a user provides bytes, they are converted to a str.
def coerce_to_str(v: Any, info: core_schema.ValidationInfo) -> str:
if isinstance(v, bytes):
return v.decode()
return str(v)
# This schema first runs coerce_to_str, then passes the result to str_schema
username_before_schema = core_schema.with_info_before_validator_function(
coerce_to_str,
core_schema.str_schema()
)
v = SchemaValidator(username_before_schema)
assert v.validate_python(b'pydantic_user') == 'pydantic_user'
The ValidationInfo object provides metadata about the validation process, such as the current mode ('python' or 'json').
Step 2: Refining with After Validators
An "After" validator runs after the inner schema has successfully validated the data. This is the most common pattern for cleaning or normalizing data. You will use AfterValidatorFunctionSchema to ensure the username is lowercase and stripped of whitespace.
def normalize_username(v: str, info: core_schema.ValidationInfo) -> str:
return v.strip().lower()
username_after_schema = core_schema.with_info_after_validator_function(
normalize_username,
username_before_schema
)
v = SchemaValidator(username_after_schema)
assert v.validate_python(' Pydantic_User ') == 'pydantic_user'
Step 3: Contextual Validation with ValidationInfo
Sometimes validation depends on external data, such as a blacklist. You can access a user-provided context through the ValidationInfo object.
def check_blacklist(v: str, info: core_schema.ValidationInfo) -> str:
# Access context passed during validate_python
blacklist = info.context.get('blacklist', []) if info.context else []
if v in blacklist:
raise ValueError(f"The username '{v}' is reserved.")
return v
username_context_schema = core_schema.with_info_after_validator_function(
check_blacklist,
username_after_schema
)
v = SchemaValidator(username_context_schema)
# Validation fails because 'admin' is in the context blacklist
try:
v.validate_python('admin', context={'blacklist': ['admin', 'root']})
except ValidationError as e:
print(e)
Step 4: Advanced Control with Wrap Validators
A "Wrap" validator gives you the most control by providing a ValidatorFunctionWrapHandler. This handler allows you to choose when (or if) to call the inner validation logic. You will use WithInfoWrapValidatorFunctionSchema to catch validation errors and provide a fallback username.
def wrap_with_fallback(
v: Any,
handler: core_schema.ValidatorFunctionWrapHandler,
info: core_schema.ValidationInfo
) -> str:
try:
# Call the inner validation logic
return handler(v)
except ValidationError:
# If inner validation fails, return a default
return "anonymous_user"
username_wrap_schema = core_schema.with_info_wrap_validator_function(
wrap_with_fallback,
username_context_schema
)
v = SchemaValidator(username_wrap_schema)
# Input that would normally fail (e.g. an integer) now returns the fallback
assert v.validate_python(123) == 'anonymous_user'
Step 5: Total Control with Plain Validators
If you want to bypass Pydantic's built-in logic entirely for a specific field, use PlainValidatorFunctionSchema. This is useful for complex types that don't map to standard schemas.
def plain_hex_validator(v: Any, info: core_schema.ValidationInfo) -> str:
if not isinstance(v, int):
raise ValueError("Expected integer")
return hex(v)
plain_schema = core_schema.with_info_plain_validator_function(plain_hex_validator)
v = SchemaValidator(plain_schema)
assert v.validate_python(255) == '0xff'
Complete Working Result
By combining these patterns, you have created a sophisticated validation pipeline. Here is the final implementation of the "Username" validator:
from pydantic_core import SchemaValidator, core_schema
def username_pipeline():
# 1. Before: Coerce input
# 2. After: Normalize (strip/lower)
# 3. After: Check context blacklist
base_schema = core_schema.str_schema(min_length=3)
schema = core_schema.with_info_before_validator_function(
coerce_to_str,
core_schema.with_info_after_validator_function(
normalize_username,
core_schema.with_info_after_validator_function(
check_blacklist,
base_schema
)
)
)
return SchemaValidator(schema)
validator = username_pipeline()
# Test successful validation
result = validator.validate_python(b' RealUser ', context={'blacklist': ['admin']})
assert result == 'realuser'
# Test context-based failure
try:
validator.validate_python('admin', context={'blacklist': ['admin']})
except Exception as e:
print("Caught expected error:", e)
This pipeline ensures that data is consistently formatted and validated against dynamic business rules before it ever reaches your application logic.