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.
| Mode | Execution Point | Use Case |
|---|---|---|
'before' | Runs on raw input before type coercion | Sanitizing strings, parsing custom formats, normalizing payloads |
'after' | Runs after successful type coercion | Range checks, cross-reference validation, business rule enforcement |
'wrap' | Intercepts the entire validation pipeline | Logging, caching, or modifying validation call chains (high overhead) |
'plain' | Bypasses Pydantic's internal validation entirely | Custom parsing logic where you handle all type conversion manually |
Execution Order: before → type coercion → after → model_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
aftervalidators. ReturningNoneor omitting the return statement breaks the validation pipeline. - Use
typing.Anyfor input parameters to safely handle pre-coercion raw data, then cast explicitly. - For generic constraints, leverage
typing.TypeVarandProtocolto 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: ValidationInfoin 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_validatorlogic 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.
- Custom Exception Handlers: Pydantic raises
pydantic.ValidationErroron failure. FastAPI automatically converts this to a422 Unprocessable Entityresponse. Override the default behavior by registering a custom exception handler to standardize error payloads across microservices. - Pre-Validation via
Depends: UseDepends()to run validation logic before model instantiation when dealing with complex authentication, multi-step workflows, or external service lookups. - OpenAPI Compliance: Validators using
mode="before"ormode="plain"do not generate JSON Schema constraints. Always prefermode="after"or Pydantic's built-inField()constraints (gt,regex,max_length) to ensure accurate OpenAPI documentation and client SDK generation.
Common Production Pitfalls
| Anti-Pattern | Impact | Correct Approach |
|---|---|---|
Using mode="wrap" for simple coercion | Adds ~30-50% validation overhead per field | Use mode="before" or mode="after" unless intercepting the validation chain is strictly necessary |
| Hardcoding rules inside model classes | Breaks DRY, complicates mocking/testing | Extract logic into parameterized factory functions or standalone validator modules |
Raising generic Exception | Bypasses Pydantic's error aggregation and FastAPI's 422 auto-formatting | Always 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.