← Back to FastAPI
Lesson 2.2 · Pydantic & Validation

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.

Beginner35 min

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.

workflowRules on every fieldstep 1 / 3

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.

seller
" Ravi_K " -> "ravi_k"
price
"19.99" (Decimal)
id
new each time
status
201

Real results: one good product, then one rule broken at a time.

Words you will see in this lesson

A few words about rules.

Small dictionary
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.

Example 1 - fields.py
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.

Output - 16 requests (long lines shortened)
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 required

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

Output - the bug, step by step
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

Numbers

Limits and steps.

Field(gt=0, ge=0, lt=100, le=10_000, multiple_of=2)
Text

Length and shape.

Field(min_length=3, max_length=50, pattern=r"^[A-Z]{3}-\d{4}$")
Money

Exact; JSON as a string.

price: Decimal = Field(gt=0, max_digits=8, decimal_places=2)
Lists

Number of items.

tags: list[str] = Field(default=[], max_length=3)
Reusable + clean

A type you can reuse.

Annotated[str, StringConstraints(strip_whitespace=True, to_lower=True)]
Email / URL

Ready-made types.

email: EmailStr;  website: HttpUrl | None = None
Fresh defaults

Made per object.

Field(default_factory=lambda: uuid4().hex[:8])
JSON name

camelCase in JSON.

Field(alias="userName")
Check defaults

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

In the docs

“Run fields.py and open /docs. Find the rules (minLength, pattern, exclusiveMinimum) in the ProductIn schema.”

Phone numbers

“Make a reusable Phone type: exactly 10 digits, spaces removed. Use it in a model.”

Float money

“Change price to float and send 0.1 + 0.2 worth of items. Compare with Decimal.”

Both names

“Make /signup accept both userName and user_name. (Search the Pydantic docs for validate_by_name.)”

What usually goes wrong

A default that breaks its own rule

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)
float for money

Floats cannot store most decimal amounts exactly. Use Decimal with decimal_places=2.

Sending the Python name after setting an alias

With alias="userName", the JSON must use userName - user_name gave 422.

Rules only in your head

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.

1.

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

What is the difference between gt and ge?
gt is "greater than"; ge is "greater than or equal".
What did " Ravi_K " become?
"ravi_k" - trimmed and lower-cased by StringConstraints.
How did the Decimal price appear in the JSON response?
As a string: "19.99".
Why did our first version give a 500?
The default pack_of=1 broke multiple_of=2; defaults are not checked, but the explicit 1 later was.

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

    Which rule refused "lmp-42"?

  2. 2.

    With alias="userName", what happens when the client sends user_name?

  3. 3.

    Why use default_factory for an id?

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