Custom Validators and Field Constraints in Pydantic
Custom validators and field constraints are how a Pydantic model rejects invalid input precisely — declarative bounds through Field, single-field rules through field_validator, and cross-field invariants through model_validator.
This is the validation core of Advanced Pydantic Validation and Serialization. Rules defined here run at the API boundary and surface in the JSON Schema, and keeping them pure is what lets the performance story hold.
Core Mechanics: Constraints, Field, and Model Validators
Declarative constraints handle the common cases without any function: Field(gt=0), Field(max_length=50), Field(pattern=...). They are fast, appear in the schema, and should be your first choice. When a rule needs logic, field_validator operates on one field; when it spans fields, model_validator operates on the whole model.
from datetime import date
from typing import Annotated
from pydantic import BaseModel, Field, field_validator, model_validator
class Booking(BaseModel):
room_id: Annotated[int, Field(gt=0)]
check_in: date
check_out: date
@field_validator("check_in", "check_out")
@classmethod
def not_in_past(cls, value: date) -> date:
if value < date.today():
raise ValueError("dates cannot be in the past")
return value
@model_validator(mode="after")
def check_out_after_check_in(self) -> "Booking":
# Cross-field rule: needs both values, so it lives at model scope.
if self.check_out <= self.check_in:
raise ValueError("check_out must be after check_in")
return self
Production Implementation: Before vs After Mode
Mode controls when a validator runs. A before validator sees the raw, uncoerced input and is ideal for normalization — trimming whitespace, parsing a custom format — turning a messy value into one Pydantic can coerce. An after validator runs on the already-typed value and is ideal for assertions.
from pydantic import BaseModel, field_validator
class Account(BaseModel):
email: str
@field_validator("email", mode="before")
@classmethod
def normalize_email(cls, value: object) -> object:
# Runs on raw input — lowercase and trim before any type checks.
return value.strip().lower() if isinstance(value, str) else value
The reusable, DRY approach using Annotated validators is detailed in Creating Reusable Custom Validators in Pydantic.
Async and Performance Notes
Validators run on the synchronous validation core, so they must be pure and cheap. A validator is invoked for every instance constructed, which on a hot path can be thousands of times per second — keep it to comparisons and transformations, never I/O. If a rule requires a database lookup, it is a business rule for the service layer, not a validator. Construct trusted internal objects with model_construct to skip validation entirely when the data is already known-good.
Testing Strategy
Test validators by asserting both acceptance and rejection, since a validator that never rejects is a silent no-op:
import pytest
from pydantic import ValidationError
def test_checkout_must_follow_checkin():
with pytest.raises(ValidationError):
Booking(room_id=1, check_in=date(2026, 7, 2), check_out=date(2026, 7, 1))
Failure Modes and Debugging
- Forgetting
@classmethod.field_validatormethods are class methods; omitting the decorator raises at class definition. - Returning
Noneby accident. A validator must return the value; forgetting to return silently nulls the field. - I/O in validators. Database or network calls in validators break purity and performance. Move them to services with dependency injection.
- Wrong mode. Using
afterfor normalization fails when the raw value cannot be coerced; normalize inbefore.
Related Reading
- Up to the section: Advanced Pydantic Validation and Serialization.
- Hands-on guide: Creating Reusable Custom Validators in Pydantic.
- Composes with: JSON Schema Customization and Type Hinting and IDE Integration.