◐ ai-teacher

🐍 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 raises ValidationError listing every bad field. FastAPI turns that into 422.
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 a field_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:

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 null

Order.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 row

What 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

Words to know

BaseModelThe Pydantic base class; annotated attributes on a subclass become validated fields.
ValidationErrorRaised when input fails; .errors() lists every problem with type, loc, msg and input.
Lax modePydantic's default: converts reasonable inputs to the declared type ('3' → 3, True → 1).
Strict modeRefuses type conversion: a str is not accepted for an int field. Per field or per model.
field_validatorA classmethod that checks or transforms one field; its return value becomes the field's value.
model_validatorA validator on the whole model, for rules that involve several fields (from ≠ to).
response_modelFastAPI option that validates and filters a handler's return value through an output model.

In two minutes

Test yourself

1. What does Order.model_validate({"item": "chai", "qty": "2"}) produce, with qty: int?

  1. A ValidationError: '2' is a string
  2. Order(item='chai', qty=2): lax mode converts the clean numeric string
  3. Order(item='chai', qty='2')
  4. 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?

  1. It's faster
  2. HTTP requires it
  3. So the server can log less
  4. 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?

  1. ValidationError: extra field 'quantity'
  2. Order(item='chai', qty=3)
  3. Order(item='chai', qty=1)
  4. 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?

  1. Use strict mode: Field(strict=True) on the field or ConfigDict(strict=True) on the model
  2. Add a default value
  3. Use int | str
  4. 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?

  1. None; both are optional
  2. The first is required (the key must be present, null allowed); the second may be omitted
  3. The first allows null, the second doesn't
  4. 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}?

  1. qty=2 (truncated)
  2. qty=3 (rounded)
  3. ValidationError int_from_float: got a number with a fractional part
  4. 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.

Learn it so it stays. Read it, test yourself, then recall it on two different days. The app schedules that for you, free.

Start this lesson →