🐍 Backend with Python · Chapter 4
Pydantic: validation as the contract
Every bad row in your database got in through a door someone forgot to check. A Pydantic model is that door, written once, enforced on every request, and published as your API's contract.
Study this lesson free in the app →
The basics
Pydantic is the Python library FastAPI uses to check data. You describe the shape you expect as a class, a model, and Pydantic turns untrusted input (JSON, query strings, dicts) into a typed Python object, or refuses it with a precise list of what's wrong.
The problem it solves: without one place that defines valid input, every handler checks a little differently, and bad data (negative fares, "two" plates, missing fields) slips into the database where it's ten times harder to clean up.
The mental model: a model is a contract at the door. Inside your code, an Order is guaranteed to have an item that's a string and a qty that's an int between 1 and 20, so you never re-check it. Outside the door, nothing is trusted.
Words you must know 4
- Model
- a class inheriting
BaseModel; each annotated attribute is a field with a type. - Validation
Model.model_validate(data)checks and converts; failure raisesValidationErrorlisting every bad field. FastAPI turns that into422.- Coercion
- by default (lax mode) Pydantic converts sensible inputs:
"3"→3. Strict mode refuses them. - Constraint
- an extra rule on a type, like
Field(gt=0, max_length=40), or your own check in afield_validator.
Explained
class Order(BaseModel):
item: str
qty: int = 1
Order.model_validate({"item": "chai", "quantity": 3})Result: item='chai' qty=1. No error.
The client typed quantity, Pydantic ignored the unknown key by default, and the customer who wanted 3 chai gets 1. Validation is only as good as the contract you write. This chapter is about writing one that actually holds.
The bank's deposit slip
At an SBI branch you fill a paying-in slip. The cashier checks it against the rules printed on it before touching your money:
- Account number: exactly 11 digits. Ten? Back to you.
- Amount in figures and in words must agree.
- Date: not in the future.
She lists every problem at once, not one per trip to the queue. Once she stamps it, the back office never re-checks the account number: the stamp is the guarantee.
Two details matter. She's sensible about format: "₹ 5,000" and "5000" are the same amount (coercion). But she won't guess: "five thousand-ish" goes back (validation error). And anything scribbled in the margin that isn't a box on the slip? Depends on the bank's policy: ignore it, or refuse the slip (extra="ignore" vs extra="forbid").
Precisely: what a model does
class Order(BaseModel):
item: str # required
qty: int = 1 # optional, default 1
note: str | None = None # optional, may be nullOrder.model_validate(data) (for dicts) or Order.model_validate_json(raw) (for raw JSON) runs, field by field: missing? → error missing unless there's a default → convert to the type (lax: "3"→3, 2.0→2, True→1; but 2.5 → int_from_float error) → constraints (gt, le, min_length, pattern) → your validators. All errors are collected, then raised together as one ValidationError.
Config on model_config = ConfigDict(...):
| Setting | Default | Effect | | --- | --- | --- | | extra | "ignore" | unknown keys dropped silently; "forbid" → error | | strict | False | True refuses "3" for int | | validate_assignment | False | True re-checks obj.field = value |
Going out: .model_dump() → dict, .model_dump_json() → JSON string. Same contract, both directions.
Constraints and your own rule
from typing import Annotated
from pydantic import BaseModel, Field, ValidationError, field_validator
class Ride(BaseModel):
fare_paise: Annotated[int, Field(gt=0, le=500_000)]
city: Annotated[str, Field(min_length=2, max_length=40)]
@field_validator("city")
@classmethod
def known_city(cls, v: str) -> str:
v = v.strip().title()
if v not in {"Pune", "Delhi", "Shillong"}:
raise ValueError(f"we don't operate in {v}")
return v
for data in [{"fare_paise": 0, "city": "x"},
{"fare_paise": 25000, "city": "Goa"},
{"fare_paise": 25000, "city": " delhi"}]:
try:
print(Ride.model_validate(data))
except ValidationError as e:
for err in e.errors():
print(err["loc"], err["type"], "-", err["msg"])Output:
('fare_paise',) greater_than - Input should be greater than 0
('city',) string_too_short - String should have at least 2 characters
('city',) value_error - Value error, we don't operate in Goa
fare_paise=25000 city='Delhi'Notice the first input produced two errors at once. The client fixes everything in one round trip. And the validator doesn't just check, it normalises: " delhi" came out as 'Delhi', so the rest of your code only ever sees the clean form. Money is in paise as an int: never a float.
One dict becomes a guaranteed Ride
Order matters: type conversion → Field constraints → field_validator (the default 'after' mode). That's why the length check saw 7 characters, not 4.
Predict: qty = True
class Order(BaseModel):
item: str
qty: int = 1
print(Order.model_validate({"item": "chai", "qty": True}))In FastAPI: one model in, another out
from typing import Annotated
from fastapi import FastAPI
from pydantic import BaseModel, ConfigDict, Field
class OrderIn(BaseModel):
model_config = ConfigDict(extra="forbid")
item: Annotated[str, Field(min_length=1, max_length=30)]
qty: Annotated[int, Field(ge=1, le=20)]
class OrderOut(BaseModel):
id: int
item: str
qty: int
total_paise: int
PRICES = {"chai": 1500, "samosa": 2000}
app = FastAPI()
@app.post("/orders", status_code=201, response_model=OrderOut)
def create_order(order: OrderIn):
row = {"id": 101, **order.model_dump(),
"total_paise": PRICES.get(order.item, 0) * order.qty,
"kitchen_note": "cashier=ravi"}
return rowWhat TestClient got back:
{"item":"chai","qty":2} → 201 {"id":101,"item":"chai","qty":2,"total_paise":3000}
{"item":"chai","qty":"3"} → 201 {… "qty":3,"total_paise":4500}
{"item":"chai","qty":0} → 422 greater_than_equal, loc ["body","qty"]
{"item":"chai","qty":2,"discount":100} → 422 extra_forbidden, loc ["body","discount"]Two contracts. OrderIn is what clients may send: no price, no discount, nothing extra. OrderOut is what they get: response_model filtered out kitchen_note even though the handler returned it. That filter is how you stop a password_hash reaching the browser.
Where contracts leak
extradefaults to"ignore". Typos (quantity) and fields you never meant to accept (is_admin) vanish silently. Useextra="forbid"on input models.str | Noneis not optional. Without= None,note: str | Noneis required (you must send it, even asnull): error typemissing.- Assignment isn't checked.
ride.fare_paise = -5succeeds and printsfare_paise=-5. Setvalidate_assignment=Trueif code mutates models. - Lax coercion surprises.
True→1,"yes"→Truefor a bool," 7 "→7. Usestrict=Trueon fields where a wrong type means a bug. - One model for everything. The same class for create, update and response leaks secrets and lets clients set
id. SplitXIn/XOut. - float for money.
fare: floatinvites0.1 + 0.2. Integers in paise, orDecimal.
Words to know
| BaseModel | The Pydantic base class; annotated attributes on a subclass become validated fields. |
|---|---|
| ValidationError | Raised when input fails; .errors() lists every problem with type, loc, msg and input. |
| Lax mode | Pydantic's default: converts reasonable inputs to the declared type ('3' → 3, True → 1). |
| Strict mode | Refuses type conversion: a str is not accepted for an int field. Per field or per model. |
| field_validator | A classmethod that checks or transforms one field; its return value becomes the field's value. |
| model_validator | A validator on the whole model, for rules that involve several fields (from ≠ to). |
| response_model | FastAPI option that validates and filters a handler's return value through an output model. |
In two minutes
- A Pydantic model declares the shape of valid data; model_validate turns untrusted input into a typed object or raises ValidationError.
- Validation reports every failing field at once; FastAPI converts it to a 422 with type, loc and msg for each.
- Lax mode (default) coerces sensible inputs like '3' → 3 and True → 1; strict mode refuses them.
- Field(...) adds constraints (gt, le, min_length, pattern); field_validator and model_validator add business rules and can normalise values.
- extra defaults to 'ignore', silently dropping unknown keys; use extra='forbid' on input models.
- Use separate input and output models; response_model filters what leaves the server.
- Validate once at the door, then trust the object inside: no defensive re-checking deep in the code.
Test yourself
1. What does Order.model_validate({"item": "chai", "qty": "2"}) produce, with qty: int?
- A ValidationError: '2' is a string
- Order(item='chai', qty=2): lax mode converts the clean numeric string
- Order(item='chai', qty='2')
- Order(item='chai', qty=None)
Show the answer
Order(item='chai', qty=2): lax mode converts the clean numeric string — Default lax mode coerces a string holding a whole number to int. In strict mode the same input would fail with int_type.
2. Why is it worth Pydantic reporting every failing field instead of stopping at the first?
- It's faster
- HTTP requires it
- So the server can log less
- The client can fix all problems in one round trip instead of discovering them one request at a time
Show the answer
The client can fix all problems in one round trip instead of discovering them one request at a time — A form with three mistakes should come back with three messages, not need three submissions. Speed isn't the reason; collecting errors does slightly more work.
3. Trap: what does Order.model_validate({"item": "chai", "quantity": 3}) give when Order has qty: int = 1 and no config?
- ValidationError: extra field 'quantity'
- Order(item='chai', qty=3)
- Order(item='chai', qty=1)
- ValidationError: qty missing
Show the answer
Order(item='chai', qty=1) — The intuitive answer is an error. But extra defaults to 'ignore': the unknown key is dropped and qty takes its default. The customer gets 1 chai. extra='forbid' turns it into extra_forbidden.
4. How do you make Pydantic refuse the string "2" for an int field?
- Use strict mode: Field(strict=True) on the field or ConfigDict(strict=True) on the model
- Add a default value
- Use int | str
- Set extra='forbid'
Show the answer
Use strict mode: Field(strict=True) on the field or ConfigDict(strict=True) on the model — Strict mode turns off coercion and reports int_type. extra='forbid' is about unknown keys, not types.
5. What is the difference between note: str | None and note: str | None = None?
- None; both are optional
- The first is required (the key must be present, null allowed); the second may be omitted
- The first allows null, the second doesn't
- The second is strict
Show the answer
The first is required (the key must be present, null allowed); the second may be omitted — | None only says null is an allowed value. Optional-ness comes from having a default. Omitting the first gives error type 'missing'.
6. Trap: class Order(BaseModel): qty: int. What happens with {"qty": 2.5}?
- qty=2 (truncated)
- qty=3 (rounded)
- ValidationError int_from_float: got a number with a fractional part
- qty=2.5
Show the answer
ValidationError int_from_float: got a number with a fractional part — Pydantic accepts 2.0 as 2 because nothing is lost, but refuses 2.5 rather than silently truncating like int(2.5) would. The intuitive 'it truncates' is Python's int(), not Pydantic.
There are 9 questions, 3 puzzles and 5 recall cards for this lesson in the app — the recall cards come back on later days, just before you would forget.
Start this lesson →