Field Validation

intermediate fastapi pydantic

Type hints get us “this must be a string”. Field() gets us “this must be a string between 3 and 50 characters, matching a slug pattern”. It’s how we encode business rules right next to the field.

In simple language — Field() is the place we attach all the little rules a value must satisfy, plus extras like example values for the docs.

Why bother

We could write a manual validator for every constraint, but that’s noisy. Field() covers 90% of common cases declaratively, and the constraints show up in the OpenAPI schema — Swagger UI displays “min length: 3” automatically.

The shape of Field()

from pydantic import BaseModel, Field
from uuid import uuid4

class CreateUser(BaseModel):
    username: str = Field(min_length=3, max_length=20, pattern=r"^[a-z0-9_]+$")
    age: int = Field(gt=0, lt=150)
    email: str = Field(..., examples=["manish@example.com"])
    bio: str | None = Field(default=None, max_length=500)
    id: str = Field(default_factory=lambda: str(uuid4()))

Two things to spot:

  • ... (Ellipsis) means “required, no default” — same as just email: str with no default.
  • default_factory is for mutable defaults (lists, dicts, UUIDs). Never use default=[] — that list is shared across instances.

Constraint cheat sheet

Numbers
gt — greater than
ge — greater or equal
lt — less than
le — less or equal
multiple_of
Strings
min_length
max_length
pattern — regex
strip_whitespace
Collections
min_length
max_length
(works on list, set, dict)

A realistic example

Product catalog endpoint. Notice how the constraints document the API for free:

from pydantic import BaseModel, Field
from decimal import Decimal

class CreateProduct(BaseModel):
    sku: str = Field(min_length=8, max_length=12, pattern=r"^[A-Z0-9-]+$")
    name: str = Field(min_length=1, max_length=200)
    price: Decimal = Field(gt=0, le=1_000_000, decimal_places=2)
    stock: int = Field(ge=0, default=0)
    tags: list[str] = Field(default_factory=list, max_length=10)
    description: str | None = Field(default=None, max_length=2000)

@app.post("/products")
def create(product: CreateProduct):
    return {"sku": product.sku}

Send price: -5 and FastAPI returns 422 before our handler runs:

{
  "detail": [{
    "loc": ["body", "price"],
    "msg": "Input should be greater than 0",
    "type": "greater_than"
  }]
}

default vs default_factory — the gotcha

Mutable defaults are evaluated once at class definition. Every instance shares the same list:

# BAD — shared list across all instances
class Cart(BaseModel):
    items: list[str] = Field(default=[])

# GOOD — fresh list every time
class Cart(BaseModel):
    items: list[str] = Field(default_factory=list)

Pydantic actually catches this and raises an error for mutable defaults in most cases, but the rule is — anytime the default needs to be “computed” (timestamp, UUID, empty list), use default_factory.

Extra metadata for docs

Field() also takes description, title, examples, deprecated. All of it flows into Swagger UI:

class CreateUser(BaseModel):
    email: str = Field(
        description="User's primary email, must be unique",
        examples=["manish@example.com"],
    )

The takeaway — type hints describe what type, Field() describes what values are acceptable. Together they encode the entire input contract.