Field Rules
Add rules to fields: number limits, text length and patterns, exact money with Decimal, list sizes, cleaned-up text, emails and URLs, defaults made at creation time, and JSON names that differ from Python names. Plus a real bug: a default that broke its own rule and caused a 500 error.
What you will be able to do
- Add number rules: gt, ge, lt, le, multiple_of
- Add text rules: min_length, max_length, pattern
- Store money exactly with Decimal, max_digits and decimal_places
- Clean text automatically and make reusable rules with Annotated
- Check emails and URLs with EmailStr and HttpUrl
- Use default_factory and aliases - and know that defaults are not checked
The idea, in plain English
In Lesson 2.1 a price of -1 was accepted, because the model only said "float". Real data has rules: a price is more than 0, a name is 3 to 50 characters, a stock code looks like LMP-0042, an email has an @. Field() lets you write these rules next to each field.
The rules do three jobs at once. They check the data (422 or ValidationError if broken). They appear in /docs and /openapi.json, so clients know the rules. And some of them clean the data for you - trimming spaces, lower-casing names.
In this lesson we give a product model a rule on every field and break each rule on purpose. We also made a real mistake: a default value that broke its own rule - and it caused a 500 error. FastAPI 0.143.0, Pydantic 2.14.0, email-validator 2.3.0.
Worked example: A ProductIn model with a rule on every field, tested with 16 requests - plus a signup model that uses camelCase JSON.
1 - A good product
All rules pass. The seller " Ravi_K " is trimmed and lower-cased to "ravi_k". A new id and time are made for the product.
Real results: one good product, then one rule broken at a time.
Words you will see in this lesson
A few words about rules.
ConstraintA rule on a value: more than 0, at most 50 characters...gt / geGreater than / greater than or equal.lt / leLess than / less than or equal.Pattern (regex)A text shape: ^[A-Z]{3}-\d{4}$ means 3 capitals, a dash, 4 digits.DecimalAn exact number type for money - no rounding surprises like 0.1 + 0.2.AliasA different name for a field in JSON: userName instead of user_name.default_factoryA function that makes a new default value each time.An everyday example: a passport form
A passport form has rules printed on it: "Surname - capital letters only", "Date of birth - DD/MM/YYYY", "Photo - 35 x 45 mm", "Phone - 10 digits". The officer checks each box against its rule and sends the form back with every broken rule marked.
Field() is where you print those rules on your model. And like a good form, the rules are visible to the person filling it in - in /docs.
Example 1 - a product with rules
Every field has a rule. name: 3 to 50 characters. sku: must match a pattern. price: a Decimal more than 0, at most 8 digits with 2 decimals. stock: 0 to 10,000. pack_of: a multiple of 2. tags: at most 3. seller: a reusable rule (Username) that trims spaces, lower-cases and checks length. email: EmailStr. website: an optional HttpUrl.
ProductOut adds two fields with default_factory: an id and a creation time, made fresh for every product. Lesson 2.5 explains output models; here we just use one to add fields.
from datetime import datetime, timezone
from decimal import Decimal
from typing import Annotated
from uuid import uuid4
from fastapi import FastAPI
from pydantic import BaseModel, EmailStr, Field, HttpUrl, StringConstraints
app = FastAPI()
# A reusable rule: text that is trimmed and lower-cased, 3 to 20 characters
Username = Annotated[str, StringConstraints(strip_whitespace=True, to_lower=True, min_length=3, max_length=20)]
class ProductIn(BaseModel):
name: str = Field(min_length=3, max_length=50, description="Product name", examples=["Desk lamp"])
sku: str = Field(pattern=r"^[A-Z]{3}-\d{4}$", description="Stock code like LMP-0042")
price: Decimal = Field(gt=0, max_digits=8, decimal_places=2, description="Price, more than 0")
stock: int = Field(default=0, ge=0, le=10_000)
pack_of: int = Field(default=2, multiple_of=2)
tags: list[str] = Field(default=[], max_length=3)
seller: Username
email: EmailStr
website: HttpUrl | None = None
class ProductOut(ProductIn):
id: str = Field(default_factory=lambda: uuid4().hex[:8]) # a new value each time
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
@app.post("/products", status_code=201)
def create_product(product: ProductIn) -> ProductOut:
return ProductOut(**product.model_dump())
class Signup(BaseModel):
user_name: str = Field(alias="userName") # JSON uses camelCase, Python uses snake_case
@app.post("/signup")
def signup(data: Signup):
return {"python_name": data.user_name, "dumped": data.model_dump(), "by_alias": data.model_dump(by_alias=True)}Every rule, broken on purpose
We sent one good product, then broke one rule per request. Each error names the field and the rule in plain words: "String should have at least 3 characters", "String should match pattern", "Input should be greater than 0", "Decimal input should have no more than 2 decimal places", "List should have at most 3 items".
Look at the seller in the good product: we sent " Ravi_K " and got back "ravi_k" - trimmed and lower-cased before the length check. " ab " became "ab", which is only 2 characters, so it failed the minimum of 3. Cleaning happens first, then checking.
Two products in a row got different ids (8a39be90, ec558041) - default_factory runs for each object.
good -> 201 {"name":"Desk lamp","sku":"LMP-0042","price":"19.99","stock":0,"pack_of":2,"tags":[],"seller":"ravi_k","email":"ravi@example.com","website":null,"id":"8a39be90","created_at":"2026-10-10T21:00:25.55835...
good again (new id) -> 201 {...,"id":"ec558041",...}
name too short -> 422 name: String should have at least 3 characters
sku wrong pattern -> 422 sku: String should match pattern '^[A-Z]{3}-\d{4}$'
price 0 -> 422 price: Input should be greater than 0
price 3 decimals -> 422 price: Decimal input should have no more than 2 decimal places
stock -5 -> 422 stock: Input should be greater than or equal to 0
pack_of 3 -> 422 pack_of: Input should be a multiple of 2
4 tags -> 422 tags: List should have at most 3 items after validation, not 4
seller ' ab ' -> 422 seller: String should have at least 3 characters
bad email -> 422 email: value is not a valid email address: An email address must have an @-sign.
bad website -> 422 website: Input should be a valid URL, relative URL without a base
website ok -> 201 {...,"website":"https://example.com/shop",...}
price as float 19.99 -> 201 {...,"price":"19.99",...}
signup camelCase -> 200 {"python_name":"ravi","dumped":{"user_name":"ravi"},"by_alias":{"userName":"ravi"}}
signup snake_case -> 422 userName: Field requiredMoney: use Decimal, and notice the JSON
A float cannot store most decimal numbers exactly - 0.1 + 0.2 is 0.30000000000000004 in Python. For money, use Decimal: it stores exactly what was sent. max_digits=8 and decimal_places=2 then say "at most 999999.99, with cents". "19.999" was refused.
One thing to know: in the JSON response the price came back as the text "19.99", in quotes - not as a number. Pydantic sends Decimal as a string so no precision is lost on the way. Clients must be ready to read a string there.
Reusable rules with Annotated
If many models need the same rule - every username, every phone number - do not repeat Field() everywhere. Write the rule once as a type with Annotated, like Username above, and use it as a normal type: seller: Username. StringConstraints can also clean text: strip_whitespace and to_lower (or to_upper) run before the length rules.
Pydantic also has ready-made types: EmailStr checks emails (it needs the email-validator package, which comes with fastapi[standard]), HttpUrl checks web addresses, and PositiveInt, NonNegativeInt and friends are shortcuts for common number rules.
Aliases: camelCase JSON, snake_case Python
JavaScript clients often use camelCase (userName); Python uses snake_case (user_name). Field(alias="userName") connects them: the JSON must say userName, and in Python you write data.user_name.
Two results to notice. Sending {"user_name": "ravi"} was refused - "userName: Field required" - once you set an alias, the alias is the name in JSON. And model_dump() gives the Python name by default; model_dump(by_alias=True) gives the JSON name.
A real bug: defaults are not checked
In our first version, pack_of had default=1 with the rule multiple_of=2. 1 is not a multiple of 2 - but nothing complained. Pydantic does NOT check default values; it trusts that you wrote them correctly.
So a product without pack_of passed ProductIn with pack_of=1. Then our endpoint did ProductOut(**product.model_dump()) - and now 1 was GIVEN, not a default, so it WAS checked, and it failed. The client got "500 Internal Server Error" for a perfectly good request.
Two fixes: write defaults that obey their rules (we changed it to 2), and let Pydantic check them with model_config = ConfigDict(validate_default=True) - then the mistake shows up the first time the model is used.
default not given -> pack_of=1 (the default is not checked)
explicit 1 -> Input should be a multiple of 2 (the same value, given, is checked)
validate_default=True -> Input should be a multiple of 2
$ curl -X POST localhost:8150/products -H "Content-Type: application/json" \
-d '{"name":"Desk lamp","sku":"LMP-0042","price":"19.99","seller":"ravi","email":"ravi@example.com"}'
Internal Server Error [500]Watch out: A 500 error means "the server broke", and the client cannot fix it by changing the request. Bugs in your own models show up as 500s - test models with their defaults, not only with full data.
Field rules at a glance
NumbersLimits and steps.
Field(gt=0, ge=0, lt=100, le=10_000, multiple_of=2)
TextLength and shape.
Field(min_length=3, max_length=50, pattern=r"^[A-Z]{3}-\d{4}$")MoneyExact; JSON as a string.
price: Decimal = Field(gt=0, max_digits=8, decimal_places=2)
ListsNumber of items.
tags: list[str] = Field(default=[], max_length=3)
Reusable + cleanA type you can reuse.
Annotated[str, StringConstraints(strip_whitespace=True, to_lower=True)]
Email / URLReady-made types.
email: EmailStr; website: HttpUrl | None = None
Fresh defaultsMade per object.
Field(default_factory=lambda: uuid4().hex[:8])
JSON namecamelCase in JSON.
Field(alias="userName")
Check defaultsCatch wrong defaults.
model_config = ConfigDict(validate_default=True)
Try it yourself
The code does not change. Swap the content string and the program does something else entirely.
“Run fields.py and open /docs. Find the rules (minLength, pattern, exclusiveMinimum) in the ProductIn schema.”
“Make a reusable Phone type: exactly 10 digits, spaces removed. Use it in a model.”
“Change price to float and send 0.1 + 0.2 worth of items. Compare with Decimal.”
“Make /signup accept both userName and user_name. (Search the Pydantic docs for validate_by_name.)”
What usually goes wrong
default=1 with multiple_of=2 passed silently, then caused a 500 later.
✗ pack_of: int = Field(default=1, multiple_of=2)✓ pack_of: int = Field(default=2, multiple_of=2)
# and model_config = ConfigDict(validate_default=True)Floats cannot store most decimal amounts exactly. Use Decimal with decimal_places=2.
With alias="userName", the JSON must use userName - user_name gave 422.
Without Field rules, a price of -1 passed (Lesson 2.1). Write every rule down - it also documents it.
Practice
Write these yourself before opening anything. Getting them wrong first is most of how this sticks.
Write a model EventIn: title (5-80 characters), seats (1 to 500), price (Decimal, 0 or more, 2 decimals), code (pattern: EV- and 4 digits), and an optional organiser email. Give it validate_default=True.
Show hintHide hint
Field(min_length=..., max_length=...), Field(ge=..., le=...), Decimal with ge=0 and decimal_places=2, pattern=r"^EV-\d{4}$", EmailStr | None = None.
Key points
- Field() adds rules: gt/ge/lt/le/multiple_of for numbers, min_length/max_length/pattern for text and lists.
- Every broken rule gives a clear message with the field name.
- Use Decimal with max_digits and decimal_places for money; it is sent as a JSON string.
- Annotated[..., StringConstraints(...)] makes reusable rules that can also clean text.
- EmailStr and HttpUrl check common formats; default_factory makes fresh defaults.
- An alias changes the JSON name; the Python name is no longer accepted in JSON.
- Defaults are not checked - write correct ones, or use validate_default=True.
Quick check before you move on
Interview questions
How do you add validation constraints to Pydantic fields?
With Field() arguments (gt, ge, lt, le, multiple_of, min_length, max_length, pattern, max_digits, decimal_places) or reusable Annotated types with constraints; they validate input and appear in the JSON schema.
Why use Decimal instead of float for money?
Floats are binary approximations; Decimal stores exact decimal values. Pydantic serializes Decimal as a string in JSON to preserve precision.
Are Pydantic defaults validated?
Not by default. A default that violates its constraints goes unnoticed until the value is passed explicitly. Enable validate_default=True to check defaults.
Quiz
- 1.
Which rule refused "lmp-42"?
- 2.
With alias="userName", what happens when the client sends user_name?
- 3.
Why use default_factory for an id?
- 4.
What does validate_default=True change?
Comments
Sign in to leave a comment. Your name and photo come from Google; nothing else is shared.
Loading comments...