Skip to main content

Validation Logic and Functions

Validation in this codebase is implemented through a multi-layered system that separates field-level constraints, model-level consistency checks, and reusable type-level logic. The implementation shifts away from the implicit validation patterns of earlier versions toward explicit "functional" validators that can be applied via decorators or Annotated metadata.

Field-Level Validation

The @field_validator decorator in pydantic/functional_validators.py is the primary mechanism for adding custom logic to specific fields of a BaseModel. Unlike standard Python properties, these validators are integrated into the Pydantic build process, allowing them to participate in the generation of the underlying core_schema.

Validation Modes

Field validators support four distinct modes that determine when the custom logic executes relative to Pydantic's internal parsing:

  • after (Default): Runs after Pydantic has parsed the input into the field's type. The validator receives the parsed object.
  • before: Runs before any Pydantic internal validation. The validator receives the raw input (often a dict or str).
  • wrap: Provides the most control by wrapping the internal validation logic. It receives a handler callable that can be invoked to trigger the standard validation at any point.
  • plain: Completely replaces Pydantic's internal validation for that field.

Implementation Requirements

In pydantic/functional_validators.py, the field_validator implementation enforces several constraints:

  1. Class Methods: Validators must be class methods. The decorator automatically attempts to wrap functions with @classmethod if they are not already wrapped, as seen in the ensure_classmethod_based_on_signature call within the dec function.
  2. Field Selection: At least one field name must be provided as a string argument.
  3. Field Checking: By default, check_fields=True ensures that the field name provided actually exists on the model, preventing silent failures due to typos.
from typing import Any
from pydantic import BaseModel, field_validator

class UserProfile(BaseModel):
username: str

@field_validator('username', mode='after')
@classmethod
def validate_username(cls, v: str) -> str:
if not v.isalnum():
raise ValueError('username must be alphanumeric')
return v.lower()

Model-Level Validation

When validation logic depends on multiple fields (cross-field validation), @model_validator is used. This decorator operates on the entire model state rather than individual fields.

The Instance Method Pattern

A significant design choice in this codebase is the preference for instance methods when using mode='after'. As implemented in pydantic/functional_validators.py, using a class method for an "after" model validator is deprecated. This is because "after" validation occurs once the model instance has already been populated, allowing the validator to access self directly.

Conversely, mode='before' validators must be class methods because they operate on the raw input data (typically a dictionary) before the model instance exists.

from typing_extensions import Self
from pydantic import BaseModel, model_validator

class Rectangle(BaseModel):
width: float
height: float

@model_validator(mode='after')
def check_dimensions(self) -> Self:
if self.width <= 0 or self.height <= 0:
raise ValueError('dimensions must be positive')
return self

Functional Validators and Annotated

The codebase introduces metadata classes—AfterValidator, BeforeValidator, PlainValidator, and WrapValidator—that allow validation logic to be embedded directly into type hints using Annotated. This decouples validation from the BaseModel structure, making logic reusable across different models or even in TypeAdapter contexts.

Wrap Validators and Handlers

The WrapValidator is particularly powerful for conditional logic. It receives a ValidatorFunctionWrapHandler, which allows the developer to attempt standard validation and then fallback or modify the result based on the outcome.

An example from tests/test_validators.py demonstrates using a wrap validator to handle a special string value ("epoch") while otherwise enforcing a date range:

from datetime import date
from typing import Annotated, Any
from pydantic import WrapValidator, ValidatorFunctionWrapHandler, ValidationInfo

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)]

Specialized Validation Annotations

Beyond standard functional validators, pydantic/functional_validators.py defines several specialized types for advanced data processing:

  • InstanceOf: A generic type that enforces isinstance checks. It is implemented using core_schema.is_instance_schema and is useful for validating objects that are not Pydantic models but must adhere to a specific class hierarchy.
  • SkipValidation: Effectively converts a field's validation schema to any_schema. This is used when a developer wants to maintain type hints for IDEs and static analysis but bypass Pydantic's runtime validation for performance or compatibility reasons.
  • ValidateAs: A helper that allows a custom type to be validated as if it were a different, Pydantic-supported type, followed by an instantiation hook. This is useful for wrapping third-party classes that cannot be modified to inherit from BaseModel.
from pydantic import BaseModel, InstanceOf, SkipValidation

class LegacySystem:
pass

class ModernModel(BaseModel):
# Enforces that 'connection' is an instance of LegacySystem
connection: InstanceOf[LegacySystem]

# Keeps the type hint but performs no runtime validation
raw_data: SkipValidation[dict[str, Any]]

Contextual Validation with ValidationInfo

Validators in this codebase can optionally accept a ValidationInfo argument. This object provides access to the validation context, including:

  • data: A dictionary of other fields that have already been validated (available in "after" validators).
  • context: Arbitrary data passed through the context argument of validate_python.
  • field_name: The name of the field currently being validated.

This allows for highly dynamic validation logic that can adapt based on the broader state of the validation process or external configuration.