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 justemail: strwith no default.default_factoryis for mutable defaults (lists, dicts, UUIDs). Never usedefault=[]— that list is shared across instances.
Constraint cheat sheet
gt — greater thange — greater or equallt — less thanle — less or equalmultiple_of
min_lengthmax_lengthpattern — regexstrip_whitespace
min_lengthmax_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.