Creating Reusable Custom Validators in Pydantic: Production Patterns

Architecting DRY, reusable validation logic in Pydantic v2 requires a shift from legacy decorator patterns to explicit execution hooks and factory functions. This guide details production-ready strategies for FastAPI applications, focusing on parameterized checks, execution boundaries, and strict error handling. For broader context on the validation lifecycle and serialization hooks, refer to the Advanced Pydantic Validation & Serialization documentation.

Understanding Pydantic V2 Validator Architecture

Pydantic v2 replaced the monolithic @validator with granular execution modes. Understanding these semantics prevents silent data corruption and validation bottlenecks.

ModeExecution PointUse Case
'before'Runs on raw input before type coercionSanitizing strings, parsing custom formats, normalizing payloads
'after'Runs after successful type coercionRange checks, cross-reference validation, business rule enforcement
'wrap'Intercepts the entire validation pipelineLogging, caching, or modifying validation call chains (high overhead)
'plain'Bypasses Pydantic's internal validation entirelyCustom parsing logic where you handle all type conversion manually

Execution Order: before → type coercion → aftermodel_validator (if applicable). When designing constraints, reserve @field_validator for single-field logic and @model_validator for relational rules. Detailed constraint implementation strategies are covered in Custom Validators & Field Constraints.

Building Reusable Field Validators

Hardcoding validation rules inside model classes violates DRY principles and complicates unit testing. Instead, use factory functions with closures to bind configuration parameters at runtime.

from pydantic import BaseModel, field_validator
from typing import Any, Callable

def range_validator(min_val: float, max_val: float) -> Callable[[Any], float]:
 """Returns a configured validator function for numeric range enforcement."""
 def _validate(v: Any) -> float:
 if not isinstance(v, (int, float)):
 raise ValueError("Input must be numeric")
 if not (min_val <= v <= max_val):
 raise ValueError(f"Value must be between {min_val} and {max_val}")
 return float(v)
 return _validate

class SensorReading(BaseModel):
 temperature: float
 humidity: float

 # Apply factory-generated validators to specific fields
 _validate_temp = field_validator("temperature", mode="after")(range_validator(-50.0, 120.0))
 _validate_humidity = field_validator("humidity", mode="after")(range_validator(0.0, 100.0))

Production Notes:

  • Always return the validated/coerced value from after validators. Returning None or omitting the return statement breaks the validation pipeline.
  • Use typing.Any for input parameters to safely handle pre-coercion raw data, then cast explicitly.
  • For generic constraints, leverage typing.TypeVar and Protocol to enforce type safety across heterogeneous schemas.

Cross-Field and Model-Level Validation Patterns

Relational business rules (e.g., start_date < end_date, tier-based limits) require @model_validator. Use mode="after" to guarantee all fields have passed individual type checks before evaluating dependencies.

from pydantic import BaseModel, model_validator, ValidationInfo
from typing import Self

class UserTier(BaseModel):
 tier: str
 monthly_limit: int

 @model_validator(mode="after")
 def check_tier_limits(self) -> Self:
 limits = {"basic": 100, "pro": 1000, "enterprise": 10000}
 max_allowed = limits.get(self.tier, 0)
 
 if self.monthly_limit > max_allowed:
 raise ValueError(f"Limit {self.monthly_limit} exceeds {self.tier} tier allowance ({max_allowed})")
 return self

Request-Scoped Context & Performance:

  • Access info: ValidationInfo in validators to inject request-scoped data (e.g., info.context.get("user_role")). This avoids passing external state through model constructors.
  • Avoid N+1 validation overhead in nested structures by validating at the parent model level rather than attaching identical validators to every child instance.
  • Keep model_validator logic stateless and O(1) where possible. Database lookups or external API calls should be deferred to FastAPI dependencies.

Integrating Validators with FastAPI

Pydantic validation integrates seamlessly with FastAPI's routing and dependency injection system.

  1. Custom Exception Handlers: Pydantic raises pydantic.ValidationError on failure. FastAPI automatically converts this to a 422 Unprocessable Entity response. Override the default behavior by registering a custom exception handler to standardize error payloads across microservices.
  2. Pre-Validation via Depends: Use Depends() to run validation logic before model instantiation when dealing with complex authentication, multi-step workflows, or external service lookups.
  3. OpenAPI Compliance: Validators using mode="before" or mode="plain" do not generate JSON Schema constraints. Always prefer mode="after" or Pydantic's built-in Field() constraints (gt, regex, max_length) to ensure accurate OpenAPI documentation and client SDK generation.

Common Production Pitfalls

Anti-PatternImpactCorrect Approach
Using mode="wrap" for simple coercionAdds ~30-50% validation overhead per fieldUse mode="before" or mode="after" unless intercepting the validation chain is strictly necessary
Hardcoding rules inside model classesBreaks DRY, complicates mocking/testingExtract logic into parameterized factory functions or standalone validator modules
Raising generic ExceptionBypasses Pydantic's error aggregation and FastAPI's 422 auto-formattingAlways raise ValueError for field-level failures or pydantic.ValidationError for model-level aggregation

Frequently Asked Questions

Can I reuse validators across multiple Pydantic models?

Yes. Define validator factories or standalone functions, then apply them via @field_validator or @model_validator. Parameterize them using closures or configuration dataclasses to adapt to different schema requirements without duplication.

How do I pass dynamic parameters to a custom validator?

Leverage Python closures or functools.partial to bind configuration at runtime. For request-scoped parameters (e.g., tenant IDs, user roles), inject them via info.context in @model_validator or use FastAPI's Depends to attach context before model instantiation.

Does Pydantic v2 support async validators?

No. Pydantic validators are strictly synchronous to maintain C-core performance guarantees and compatibility with the validation loop. Handle async operations (DB queries, external API calls) in FastAPI dependencies prior to model instantiation, or use asyncio.to_thread for blocking I/O if absolutely necessary.