Skip to main content

Custom Validators in Pydantic: Beyond Built-In Checks

Pydantic's built-in field constraints handle common validation tasks: string length, numeric ranges, email format. But real-world applications have domain-specific rules that constraints alone cannot express. A password must not contain the username. An order cannot ship to a country where the customer hasn't paid taxes. A bank account transfer must respect daily limits. For these rules, you write custom validators—Python functions decorated with @field_validator or @model_validator that execute arbitrary logic and raise ValidationError on failure.

Field Validators vs. Model Validators

Pydantic provides two decorator styles:

  • @field_validator: Runs per field, after coercion but before model instantiation. Use for single-field logic.
  • @model_validator: Runs after all fields are validated. Use for cross-field logic (field A must be greater than field B) or post-processing the entire model.

Field validators are faster (earlier in the pipeline), but model validators are necessary when one field's validity depends on another field's value.

Writing Field Validators

A field validator is a method decorated with @field_validator("field_name") that receives the field value and returns the validated (or transformed) value:

from pydantic import BaseModel, field_validator, ValidationError

class User(BaseModel):
username: str
password: str

@field_validator("username")
@classmethod
def validate_username(cls, v):
# v is the field value
if not v.isalnum():
raise ValueError("username must be alphanumeric")
return v.lower() # Transform: convert to lowercase

@field_validator("password")
@classmethod
def validate_password(cls, v):
if len(v) < 8:
raise ValueError("password must be at least 8 characters")
if not any(c.isupper() for c in v):
raise ValueError("password must contain at least one uppercase letter")
return v

# Valid
user = User(username="Alice123", password="SecurePass123")
print(user.username) # "alice123" (lowercased by validator)

# Invalid: username not alphanumeric
try:
bad = User(username="Alice-123", password="SecurePass123")
except ValidationError as e:
print(e.json()) # Error: "username must be alphanumeric"

# Invalid: password too short
try:
bad2 = User(username="Alice123", password="Short1")
except ValidationError as e:
print(e.json()) # Error: "password must be at least 8 characters"

Key points:

  • The method must be a @classmethod.
  • The first parameter is cls (the model class); the second is v (the field value).
  • Return the validated/transformed value, or raise ValueError (Pydantic wraps it as ValidationError).
  • Use @field_validator("field1", "field2") to validate multiple fields with the same logic.

Model Validators for Cross-Field Logic

When one field's validity depends on another, use @model_validator:

from pydantic import BaseModel, field_validator, model_validator, ValidationError

class PaymentInfo(BaseModel):
card_type: str # "credit" or "debit"
cvv: str

@field_validator("card_type")
@classmethod
def validate_card_type(cls, v):
if v not in ["credit", "debit"]:
raise ValueError("card_type must be 'credit' or 'debit'")
return v

@model_validator(mode="after")
def validate_cvv_length(self):
# Access other fields via self
if self.card_type == "credit":
if len(self.cvv) != 3:
raise ValueError("credit card CVV must be 3 digits")
elif self.card_type == "debit":
if len(self.cvv) != 4:
raise ValueError("debit card CVV must be 4 digits")
return self

# Valid: credit card with 3-digit CVV
payment = PaymentInfo(card_type="credit", cvv="123")

# Invalid: credit card with 4-digit CVV
try:
bad = PaymentInfo(card_type="credit", cvv="1234")
except ValidationError as e:
print("credit card CVV must be 3 digits")

Model validators come in two modes:

  • mode="after": Runs after all fields are coerced and field validators run. Use this for cross-field logic.
  • mode="before": Runs before field coercion. Use this for preprocessing raw input.

Practical Example: Airline Booking Validator

Here's a realistic scenario combining field and model validators:

from pydantic import BaseModel, field_validator, model_validator, ValidationError
from datetime import datetime, timedelta

class FlightBooking(BaseModel):
passenger_name: str
seat_number: str
departure_date: datetime
arrival_date: datetime

@field_validator("passenger_name")
@classmethod
def validate_name(cls, v):
if not v.replace(" ", "").isalpha():
raise ValueError("passenger name must contain only letters and spaces")
return v.title() # Capitalize: "alice smith" -> "Alice Smith"

@field_validator("seat_number")
@classmethod
def validate_seat(cls, v):
# Seat format: letter(s) followed by number, e.g., "12A", "1C"
if not (v[-1].isalpha() and v[:-1].isdigit()):
raise ValueError("seat number format invalid (e.g., '12A')")
return v.upper()

@model_validator(mode="after")
def validate_dates(self):
# Arrival must be after departure
if self.arrival_date <= self.departure_date:
raise ValueError("arrival date must be after departure date")

# Flight cannot be more than 24 hours
duration = self.arrival_date - self.departure_date
if duration > timedelta(hours=24):
raise ValueError("flight duration exceeds 24 hours")

return self

# Valid
booking = FlightBooking(
passenger_name="alice smith",
seat_number="12a",
departure_date=datetime(2026, 6, 15, 8, 0),
arrival_date=datetime(2026, 6, 15, 16, 0)
)
print(booking.passenger_name) # "Alice Smith"
print(booking.seat_number) # "12A"

# Invalid: arrival before departure
try:
bad = FlightBooking(
passenger_name="Bob Johnson",
seat_number="5C",
departure_date=datetime(2026, 6, 15, 16, 0),
arrival_date=datetime(2026, 6, 15, 8, 0)
)
except ValidationError as e:
print("arrival date must be after departure date")

Advanced: Conditional Validators and Dependencies

For complex logic, check multiple fields and apply conditional rules:

from pydantic import BaseModel, model_validator

class OrderShipping(BaseModel):
country: str
shipping_method: str # "standard" or "express"
is_international: bool

@model_validator(mode="after")
def validate_shipping(self):
# Rule: international orders cannot use standard shipping
if self.is_international and self.shipping_method == "standard":
raise ValueError(
"international orders require 'express' shipping"
)

# Rule: some countries don't support express shipping
if self.country in ["Antarctica", "NorthPole"] and self.shipping_method == "express":
raise ValueError(
f"express shipping unavailable to {self.country}"
)

return self

# Valid
order = OrderShipping(
country="Japan",
shipping_method="express",
is_international=True
)

# Invalid: international order with standard shipping
try:
bad = OrderShipping(
country="Japan",
shipping_method="standard",
is_international=True
)
except Exception as e:
print("international orders require 'express' shipping")

Accessing Other Configuration Context

Validators can access the model's entire context via ValidationInfo:

from pydantic import BaseModel, field_validator, ValidationInfo

class User(BaseModel):
email: str
role: str

@field_validator("email")
@classmethod
def validate_email_domain(cls, v, info: ValidationInfo):
# Access other field values via info.data
role = info.data.get("role")

# Admin accounts must use company email
if role == "admin" and not v.endswith("@company.com"):
raise ValueError("admin accounts must use @company.com email")

return v

# Valid: admin with company email
admin = User(email="[email protected]", role="admin")

# Invalid: admin with non-company email
try:
bad = User(email="[email protected]", role="admin")
except Exception as e:
print("admin accounts must use @company.com email")

Error Messages and Custom Exceptions

Raise ValueError to produce a validation error with a custom message. For more control, raise PydanticCustomError:

from pydantic import BaseModel, field_validator
from pydantic_core import PydanticCustomError

class Product(BaseModel):
price: float

@field_validator("price")
@classmethod
def validate_price(cls, v):
if v <= 0:
raise ValueError("price must be positive")
if v > 1_000_000:
raise PydanticCustomError(
"price_too_high",
"price exceeds maximum allowed ({max_price})",
{"max_price": 1_000_000}
)
return v

Custom errors include a code and context dict, useful for localizing error messages or building client-friendly responses.

Key Takeaways

  • Use @field_validator for single-field logic; it runs early in validation.
  • Use @model_validator(mode="after") for cross-field logic that requires multiple fields.
  • Always return the validated/transformed value from field validators.
  • Raise ValueError or PydanticCustomError to signal validation failure.
  • Access other field values via info.data (field validator) or self (model validator).
  • Validators can transform data (lowercase, title case) in addition to validating.

Frequently Asked Questions

Do validators run in a specific order?

Field validators run in field definition order. Model validators (mode="after") run after all field validators complete. You can chain multiple validators on the same field with repeated @field_validator decorators.

Can I make a validator optional, running only if the field is provided?

Use mode="before" in @field_validator and check v is None. Or use ValidationInfo to skip validation based on context.

What's the performance cost of custom validators?

Field validators add microseconds per field. For a 20-field model with simple validators (string checks, range checks), total overhead is 0.5-2ms. Complex validators (regex, database queries) are slower. Profile your validators if performance is critical.

Can validators access the database or external services?

Yes, but it's unusual. Validators are meant for stateless data checks. For stateful checks (unique email in database), consider a separate validation step after model instantiation or use field_validator with mode="before" to fetch data from context.

How do I reuse validation logic across multiple models?

Define a validator function and apply it to multiple models, or create a base class with shared validators and inherit from it in child models.

Further Reading