Skip to main content

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 @classmethod decorator is omitted, as seen in pydantic/functional_validators.py where ensure_classmethod_based_on_signature is called.
  • @model_validator: Operates on the entire model. In mode='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.

ModeDescriptionInput Type
beforeRuns before any internal Pydantic parsing.Any (raw input)
afterRuns after Pydantic has parsed the data into the target type.Target Type
wrapWraps the validation process, allowing manual control over the next step.Any + handler
plainCompletely 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

  1. Field Ordering: info.data only 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 via info.data.
  2. Instance vs. Class Methods: While Pydantic 1.x relied heavily on instance methods for some validation, this engine enforces classmethod for @field_validator to ensure validation can occur before an instance is fully initialized.
  3. Performance: BeforeValidator and WrapValidator operate on Any types, which can bypass some of the performance optimizations in pydantic-core compared to AfterValidator, which operates on already-coerced types.
  4. 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).