Validation Engine
The validation engine in this codebase is designed as a multi-layered pipeline that separates data parsing from business logic verification. It provides two primary interfaces for defining validation logic: Functional Validators (class-level decorators) and Annotated Metadata (type-level reusable logic).
Validation Paradigms
Functional Validators
Functional validators are defined directly on a BaseModel using decorators. This approach is ideal for validation logic that is specific to a particular model or requires access to other fields within that model.
@field_validator: Targets one or more specific fields. By default, these are treated as class methods, even if the@classmethoddecorator is omitted, as seen inpydantic/functional_validators.pywhereensure_classmethod_based_on_signatureis called.@model_validator: Operates on the entire model. Inmode='after', it receives the model instance itself, allowing for cross-field validation after individual fields have been processed.
from pydantic import BaseModel, field_validator, model_validator
from typing import Any
from typing_extensions import Self
class UserProfile(BaseModel):
username: str
age: int
@field_validator('username')
def validate_username(cls, v: str) -> str:
if not v.isalnum():
raise ValueError('username must be alphanumeric')
return v
@model_validator(mode='after')
def check_consistency(self) -> Self:
if self.username == 'admin' and self.age < 18:
raise ValueError('Admins must be adults')
return self
Annotated Metadata
For reusable validation logic that can be shared across different models, the engine utilizes typing.Annotated combined with validator classes like AfterValidator, BeforeValidator, PlainValidator, and WrapValidator. These classes implement __get_pydantic_core_schema__ to inject validation logic into the underlying pydantic-core schema.
from typing import Annotated
from pydantic import AfterValidator, BaseModel
def increment(v: int) -> int:
return v + 1
# Reusable type with validation logic
MyInt = Annotated[int, AfterValidator(increment)]
class Model(BaseModel):
a: MyInt
Validation Modes
The engine supports four distinct modes that determine when and how a validator is executed relative to Pydantic's internal parsing logic.
| Mode | Description | Input Type |
|---|---|---|
before | Runs before any internal Pydantic parsing. | Any (raw input) |
after | Runs after Pydantic has parsed the data into the target type. | Target Type |
wrap | Wraps the validation process, allowing manual control over the next step. | Any + handler |
plain | Completely replaces Pydantic's internal validation for that type. | Any |
The Wrap Validator
The WrapValidator is the most powerful mode, providing a ValidatorFunctionWrapHandler to the validator function. This allows the validator to catch errors from the internal validation, modify the input before passing it down, or short-circuit the process entirely.
An example from tests/test_validators.py demonstrates using a wrap validator to handle a special string value while falling back to standard date parsing:
from datetime import date
from typing import Any, Annotated
from pydantic import WrapValidator, ValidationInfo, ValidatorFunctionWrapHandler
def sixties_validator(val: Any, handler: ValidatorFunctionWrapHandler, info: ValidationInfo) -> date:
if val == 'epoch':
return date.fromtimestamp(0)
# Call the standard validation handler
newval = handler(val)
if not date.fromisoformat('1960-01-01') <= newval < date.fromisoformat('1970-01-01'):
raise ValueError(f'{val} is not in the sixties!')
return newval
SixtiesDate = Annotated[date, WrapValidator(sixties_validator)]
Validation Metadata and Context
Validator functions can optionally accept a ValidationInfo object (defined as a Protocol in pydantic_core), which provides metadata about the current validation state.
Accessing Other Fields
In @field_validator functions, info.data provides a dictionary of fields that have already been validated. This is a common pattern for dependent field validation, as seen in tests/test_validators.py:
@field_validator('b')
@classmethod
def b_length(cls, v, info: ValidationInfo):
values = info.data
if 'a' in values and len(v) < values['a']:
raise ValueError('b too short')
return v
External Context
Validation can be influenced by external state passed via the context argument in methods like model_validate. This context is accessible via info.context.
# Usage in a validator
def validate_with_context(v: int, info: ValidationInfo) -> int:
multiplier = info.context.get('multiplier', 1) if info.context else 1
return v * multiplier
# Passing context during validation
model = MyModel.model_validate({'a': 10}, context={'multiplier': 2})
Design Constraints and Tradeoffs
- Field Ordering:
info.dataonly contains fields that have already been validated. Because Pydantic validates fields in the order they are defined in the class, a validator for the first field cannot access the value of the second field viainfo.data. - Instance vs. Class Methods: While Pydantic 1.x relied heavily on instance methods for some validation, this engine enforces
classmethodfor@field_validatorto ensure validation can occur before an instance is fully initialized. - Performance:
BeforeValidatorandWrapValidatoroperate onAnytypes, which can bypass some of the performance optimizations inpydantic-corecompared toAfterValidator, which operates on already-coerced types. - Validation vs. Transformation: The engine encourages validators to return the value (potentially transformed). This allows validators to act as both guards and data cleansers (e.g., stripping whitespace from strings).