In the previous lesson, you learned that Pydantic validates types automatically. But type validation alone is not enough. A float field accepts -999.99. A str field accepts a 10-million-character string. An int field accepts 0 when your business logic requires at least 1. This lesson is about closing those gaps.
Pydantic gives you two layers of validation beyond basic types: Field() constraints for common rules, and @field_validator / @model_validator decorators for custom logic.
Field constraints
The Field() function lets you add constraints directly in the field definition. This is the fastest way to add validation, and it's what AI should generate but rarely does.
from pydantic import BaseModel, Field
class Product(BaseModel):
name: str = Field(min_length=1, max_length=200)
description: str = Field(default="", max_length=5000)
price: float = Field(gt=0) # strictly greater than 0
quantity: int = Field(ge=0, le=10000) # 0 to 10,000 inclusive
sku: str = Field(pattern=r"^[A-Z]{2}-\d{4}$") # e.g. "AB-1234"from pydantic import BaseModel, Field
class Product(BaseModel):
name: str = Field(min_length=1, max_length=200)
description: str = Field(default="", max_length=5000)
price: float = Field(gt=0) # strictly greater than 0
quantity: int = Field(ge=0, le=10000) # 0 to 10,000 inclusive
sku: str = Field(pattern=r"^[A-Z]{2}-\d{4}$") # e.g. "AB-1234"Here's the full list of constraints you'll use most:
| Constraint | Applies to | Meaning | Example |
|---|---|---|---|
gt | numbers | Greater than | Field(gt=0), must be > 0 |
ge | numbers | Greater than or equal | Field(ge=1), must be >= 1 |
lt | numbers | Less than | Field(lt=100), must be < 100 |
le | numbers | Less than or equal | Field(le=999), must be <= 999 |
min_length | strings, lists | Minimum length | Field(min_length=1), no empty strings |
max_length | strings, lists | Maximum length | Field(max_length=500), cap input length |
pattern | strings | Regex pattern | Field(pattern=r"^\d{5}, 5-digit zip |
multiple_of | numbers | Must be divisible by | Field(multiple_of=0.01), cent precision |
price: float with no constraint. That means your API happily accepts price: -50.0 and stores a negative price in your database. Always check that numeric fields have gt=0 or ge=0 depending on the business rule.Field metadata for documentation
Field() also accepts metadata that FastAPI uses to generate better APIWhat is api?A set of rules that lets one program talk to another, usually over the internet, by sending requests and getting responses.
class UserCreate(BaseModel):
username: str = Field(
min_length=3,
max_length=30,
pattern=r"^[a-zA-Z0-9_]+$",
description="Alphanumeric username, 3-30 characters",
examples=["john_doe", "alice42"]
)
email: str = Field(
description="User's email address",
examples=["user@example.com"]
)class UserCreate(BaseModel):
username: str = Field(
min_length=3,
max_length=30,
pattern=r"^[a-zA-Z0-9_]+$",
description="Alphanumeric username, 3-30 characters",
examples=["john_doe", "alice42"]
)
email: str = Field(
description="User's email address",
examples=["user@example.com"]
)class UserCreate(BaseModel):
username: str = Field(
min_length=3,
max_length=30,
pattern=r"^[a-zA-Z0-9_]+$",
description="Alphanumeric username, 3-30 characters",
examples=["john_doe", "alice42"]
)
email: str = Field(
description="User's email address",
examples=["user@example.com"]
)The description and examples show up in the auto-generated Swagger docs. It's a small detail, but it makes your API self-documenting.
Custom field validators
When Field() constraints aren't enough, you write a @field_validator. This is a class method that receives the raw value and must either return the (possibly modified) value or raise a ValueError.
from pydantic import BaseModel, field_validator
class UserCreate(BaseModel):
email: str
password: str
username: str
@field_validator("email")
@classmethod
def validate_email(cls, v: str) -> str:
if "@" not in v or "." not in v.split("@")[-1]:
raise ValueError("Invalid email format")
return v.lower() # Normalize to lowercase
@field_validator("password")
@classmethod
def validate_password(cls, v: str) -> str:
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 an uppercase letter")
if not any(c.isdigit() for c in v):
raise ValueError("Password must contain a digit")
return vKey rules for @field_validator:
- Decorate with
@classmethod(required in Pydantic v2) - The first argument after
clsis the raw value - Return the value to accept it, you can transform it (like
.lower()) - Raise
ValueErrorto reject it, Pydantic wraps this into aValidationError - You can validate multiple fields with one validator:
@field_validator("email", "username")
The danger of silent transformation
There is a critical difference between validating and transforming. Look at this validator that AI might generate:
class ContactForm(BaseModel):
phone: str
@field_validator("phone")
@classmethod
def clean_phone(cls, v: str) -> str:
# Strip everything that isn't a digit
return "".join(c for c in v if c.isdigit())This looks helpful, it "cleans" phone numbers by stripping non-digit characters. But think about what happens with malicious input:
- Input:
"555-123-4567"becomes"5551234567", fine - Input:
"<script>alert('xss')</script>"becomes"", silently accepted as empty - Input:
"DROP TABLE users; --123"becomes"123", silently accepted
The validator hid the fact that garbage was submitted. A better approach: reject bad input and tell the user why.
@field_validator("phone")
@classmethod
def validate_phone(cls, v: str) -> str:
cleaned = v.replace("-", "").replace(" ", "").replace("(", "").replace(")", "")
if not cleaned.isdigit() or len(cleaned) < 10:
raise ValueError("Phone must be at least 10 digits (only digits, spaces, dashes, parens allowed)")
return cleanedModel validators for cross-field logic
Sometimes validation depends on relationships between fields. A @model_validator runs after all individual fields are validated and has access to the entire model.
from pydantic import BaseModel, model_validator
class DateRange(BaseModel):
start_date: str
end_date: str
@model_validator(mode="after")
def check_date_order(self) -> "DateRange":
if self.start_date >= self.end_date:
raise ValueError("end_date must be after start_date")
return self
class DiscountRule(BaseModel):
discount_type: str # "percentage" or "fixed"
discount_value: float
@model_validator(mode="after")
def validate_discount(self) -> "DiscountRule":
if self.discount_type == "percentage" and not (0 < self.discount_value <= 100):
raise ValueError("Percentage discount must be between 0 and 100")
if self.discount_type == "fixed" and self.discount_value <= 0:
raise ValueError("Fixed discount must be positive")
return selfTwo modes for model validators:
| Mode | When it runs | Input type | Use case |
|---|---|---|---|
mode="before" | Before any field validation | Raw dict/data | Preprocessing, data reshaping |
mode="after" | After all fields are validated | Model instance | Cross-field validation, business rules |
Use mode="after" for most cases. Use mode="before" only when you need to reshape incoming data before field validation kicks in (rare, but sometimes needed for backward compatibility).
Combining constraints and validators
The best models use Field() for simple constraints and @field_validator for complex logic:
from pydantic import BaseModel, Field, field_validator, model_validator
class OrderCreate(BaseModel):
customer_email: str = Field(min_length=5, max_length=255)
items: list[str] = Field(min_length=1, max_length=50)
quantity: int = Field(ge=1, le=100)
coupon_code: str | None = None
@field_validator("customer_email")
@classmethod
def validate_email(cls, v: str) -> str:
if "@" not in v:
raise ValueError("Must be a valid email address")
return v.lower()
@field_validator("coupon_code")
@classmethod
def validate_coupon_format(cls, v: str | None) -> str | None:
if v is not None and not v.isalnum():
raise ValueError("Coupon code must be alphanumeric")
return v.upper() if v else v
@model_validator(mode="after")
def check_business_rules(self) -> "OrderCreate":
if self.quantity > 10 and self.coupon_code:
raise ValueError("Bulk orders cannot use coupon codes")
return selfThis layered approach keeps your models readable: Field() handles the obvious constraints at a glance, validators handle the nuanced logic, and model validators handle the cross-field rules.
Field() constraints first, then @field_validator methods (in declaration order), then @model_validator. If a Field() constraint rejects a value, the @field_validator for that field never runs.