FastAPI/
Lesson

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"
quot;) # e.g. "AB-1234"" data-fullscreen-lang="python" class="absolute top-2 right-8 p-1 rounded bg-gray-700 hover:bg-gray-600 text-gray-400 hover:text-gray-200 transition-colors opacity-0 group-hover/code:opacity-100 z-10" title="Fullscreen">
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"
quot;) # e.g. "AB-1234"" class="absolute top-2 right-2 p-1 rounded bg-gray-700 hover:bg-gray-600 text-gray-400 hover:text-gray-200 transition-colors opacity-0 group-hover/code:opacity-100 z-10" title="Copy">
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:

ConstraintApplies toMeaningExample
gtnumbersGreater thanField(gt=0), must be > 0
genumbersGreater than or equalField(ge=1), must be >= 1
ltnumbersLess thanField(lt=100), must be < 100
lenumbersLess than or equalField(le=999), must be <= 999
min_lengthstrings, listsMinimum lengthField(min_length=1), no empty strings
max_lengthstrings, listsMaximum lengthField(max_length=500), cap input length
patternstringsRegex patternField(pattern=r"^\d{5}Field(pattern=r"^\d{5}__INLINE_CODE_16__quot;)quot;), 5-digit zip
multiple_ofnumbersMust be divisible byField(multiple_of=0.01), cent precision
AI pitfall
When you ask AI to generate a product model, it almost always writes 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.
02

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. docs:

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"]
    )
quot;, description="Alphanumeric username, 3-30 characters", examples=["john_doe", "alice42"] ) email: str = Field( description="User's email address", examples=["user@example.com"] )" data-fullscreen-lang="python" class="absolute top-2 right-8 p-1 rounded bg-gray-700 hover:bg-gray-600 text-gray-400 hover:text-gray-200 transition-colors opacity-0 group-hover/code:opacity-100 z-10" title="Fullscreen">
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"]
    )
quot;, description="Alphanumeric username, 3-30 characters", examples=["john_doe", "alice42"] ) email: str = Field( description="User's email address", examples=["user@example.com"] )" class="absolute top-2 right-2 p-1 rounded bg-gray-700 hover:bg-gray-600 text-gray-400 hover:text-gray-200 transition-colors opacity-0 group-hover/code:opacity-100 z-10" title="Copy">
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.

03

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 v

Key rules for @field_validator:

  • Decorate with @classmethod (required in Pydantic v2)
  • The first argument after cls is the raw value
  • Return the value to accept it, you can transform it (like .lower())
  • Raise ValueError to reject it, Pydantic wraps this into a ValidationError
  • You can validate multiple fields with one validator: @field_validator("email", "username")
04

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 cleaned
AI pitfall
AI loves to write "cleaning" validators that silently strip bad characters. This feels convenient but hides bugs and creates security risks. Validators should reject bad data with a clear error message. Only transform data when the transformation is lossless and expected (like lowercasing an email).
05

Model 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 self

Two modes for model validators:

ModeWhen it runsInput typeUse case
mode="before"Before any field validationRaw dict/dataPreprocessing, data reshaping
mode="after"After all fields are validatedModel instanceCross-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).

06

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 self

This 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.

Good to know
Validators run in order: 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.