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 adictorstr).wrap: Provides the most control by wrapping the internal validation logic. It receives ahandlercallable 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:
- Class Methods: Validators must be class methods. The decorator automatically attempts to wrap functions with
@classmethodif they are not already wrapped, as seen in theensure_classmethod_based_on_signaturecall within thedecfunction. - Field Selection: At least one field name must be provided as a string argument.
- Field Checking: By default,
check_fields=Trueensures 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 enforcesisinstancechecks. It is implemented usingcore_schema.is_instance_schemaand 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 toany_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 fromBaseModel.
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 thecontextargument ofvalidate_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.