ap.Abhishek Patel

Search

Search notes, essays, projects, the library and the AI course. Use arrow keys to choose and Enter to open.

Lesson 6 of 10 25 min

Type hints and Pydantic

Write typed function signatures, use a type checker to catch mistakes before running, and validate outside data, especially LLM replies, with Pydantic models that coerce safely and reject the rest.

Words in this lessonType hint · Type checker · Pydantic · Validation · JSON Schema · Structured output
Type hint
A label in code that says what type a variable, parameter or return value should be. Like the label on a pantry jar: it says "sugar", but it cannot stop someone pouring in salt.
Type checker
A tool that reads your code without running it and reports type mistakes. Like a food-safety inspector who checks every jar label against its contents before service.
Pydantic
A Python library that checks and converts incoming data to match a model you define. Like a receiving clerk who checks every delivery against the order sheet and sends back the wrong ones.
Validation
Checking that data has the right shape and acceptable values before using it. Like checking a cake order slip is complete and sensible before the baker starts.
JSON Schema
A standard, machine-readable description of what valid JSON looks like. Like a blank order form that shows exactly which boxes must be filled and how.
Structured output
An AI model's reply forced to follow a fixed shape you choose, so code can read it reliably. Like handing a customer a printed form instead of asking them to write a letter.
On this page
  1. Arjun's first attempt: a hint is not a lock
  2. Labels on jars
  3. Writing the labels: type hints from simple to complex
  4. Optional values, unions and fixed choices
  5. The inspector: type checkers
  6. The receiving clerk: Pydantic
  7. Following one WhatsApp message through the clerk
  8. Opening the hood: validation, coercion and schemas
  9. Every model is also a schema
  10. Your own rules, nested models and going back out
  11. The front door and the back door: requests and responses
  12. Why this matters more in AI apps
  13. Where it breaks
  14. Arjun explains it to Meera
  15. Try it yourself
  16. Common mistakes
  17. Recap
  18. Go deeper

Meera is scrolling through the new “draft orders” screen when she stops. “Did someone order four lakh rupees of plum cake?”

The draft says: 2 × Plum cake, total ₹450450.

The feature is Arjun’s newest. A customer sends a WhatsApp message like “Hi! 2 plum cakes for Saturday please, no nuts. Kabir”. The bot asks Claude to pull out the item, quantity and price as JSON, and the billing code turns that into a draft order for staff to confirm. This time, Claude sent back "price": "450", with quotes around the number, so it arrived as text. And in Python, the text "450" times 2 isn’t 900. It’s "450" written twice.

“Python didn’t complain,” Arjun says. “I even put type hints on the function: price: int.”

Priya nods slowly. “Then today you find out what type hints actually do. And what they don’t.”

Important

The idea in one sentence: Type hints describe what kind of value each variable and function expects, so editors and type checkers can catch mistakes in your own code before it runs; Pydantic turns those same descriptions into real checks at runtime, converting or rejecting data that arrives from outside, which matters most when that data comes from an LLM.

Arjun’s first attempt: a hint is not a lock

Here’s the billing function, with the hints Arjun was proud of:

def line_total(price: int, qty: int) -> int:
    return price * qty

print(line_total(450, 2))
print(line_total("450", 2))
900
450450

The second call breaks every promise the signature makes: price is a str, and the result is a str too. Python runs it anyway, without a murmur.

This is the most common misconception about type hints, so let’s be precise: Python ignores type hints when it runs your code. They’re notes. price: int means “this is meant to be an int”, the same way -> int means “this is meant to return an int”. Nothing checks them while the program runs.

So why write them at all? Priya explains with the pantry.

Labels on jars

“Every jar in our pantry has a label,” Priya says. “‘Sugar’, ‘Salt’, ‘Kesar’. Does the label stop someone pouring salt into the sugar jar?”

“No.”

“So what’s the label for?”

Arjun thinks. “So the cooks know what’s in it. And so it’s obvious when something’s in the wrong jar.”

“Right. And who reads the labels? Three people. The cooks, every day. A food-safety inspector, who walks the pantry before service and points at any jar where the label and the contents don’t match. And, if we hire one, a receiving clerk at the back door, who checks every delivery against the order sheet and sends back the bags of salt labelled ‘sugar’.”

That maps exactly onto Python:

In the pantry In Python When it acts
The label A type hint, like price: int Never, by itself
The cooks You and your teammates, reading code; your editor’s autocomplete While writing
The inspector A type checker such as mypy or Pyright Before running
The receiving clerk Pydantic At runtime, when data arrives

Let’s meet all three, starting with the labels.

Writing the labels: type hints from simple to complex

A type hint is written after a colon for variables and parameters, and after -> for a function’s return value.

store: str = "Baner"
ovens: int = 4
rating: float = 4.6
is_open: bool = True


def apply_discount(price: int, percent: float = 0.0) -> float:
    return price * (1 - percent / 100)


print(apply_discount(450, 10))
405.0

Collections say what’s inside them using square brackets:

menu: dict[str, int] = {"Butter croissant": 85, "Plum cake": 450}   # name -> price
tags: list[str] = ["eggless", "bestseller"]
line: tuple[str, int] = ("Plum cake", 2)                               # (item, qty)
stores: set[str] = {"Baner", "FC Road", "Kothrud"}

Cart = list[tuple[str, int]]          # a "type alias": a name for a longer type


def cart_total(cart: Cart, menu: dict[str, int]) -> int:
    return sum(menu[item] * qty for item, qty in cart)


print(cart_total([("Plum cake", 2), ("Butter croissant", 4)], menu))
1240

A type alias like Cart gives a long type a short, meaningful name. It’s documentation you can reuse.

Optional values, unions and fixed choices

Remember the three kinds of “missing” from the data handling lesson? Type hints can say when None is allowed:

from typing import Literal, Optional

# "A str, or None". Two ways to write the same thing:
allergy_note: str | None = None          # modern style (Python 3.10+)
delivery_slot: Optional[str] = None      # older style, still common

# A union: one of several types
amount: int | float = 99.5

# Literal: only these exact values are allowed
Category = Literal["late_delivery", "refund", "allergy", "other"]


def route(category: Category) -> str:
    if category == "allergy":
        return "food-safety team"
    return "support inbox"


print(route("allergy"))
food-safety team

Literal is especially useful in AI apps. When you ask a model to classify a support email, there’s a fixed list of valid answers. Literal writes that list into the type.

You’ve also already used type hints without knowing their full purpose: dataclasses need them to know which fields exist. name: str in a dataclass is the same kind of label.

The inspector: type checkers

A type checker reads your code without running it, and reports every place where the labels and the values don’t match. The two most common are mypy and Pyright (Pyright powers the Python support in VS Code, which is why your editor underlines some mistakes in red). Run mypy on Arjun’s file:

uv add --dev mypy
uv run mypy billing.py
billing.py:4: error: Argument 1 to "line_total" has incompatible type "str"; expected "int"  [arg-type]
Found 1 error in 1 file (checked 1 source file)

The inspector caught line_total("450", 2) without running anything. In a large codebase, that’s enormously valuable: rename a field, change a function’s signature, and the checker lists every line you forgot to update.

So, does that solve the plum cake problem?

Note

Pause and think: In the real bot, the code isn’t line_total("450", 2). It’s data = json.loads(reply_text) followed by line_total(data["price"], data["qty"]). Would mypy have caught the ₹450450 bug there?

Show the answer

No. json.loads can return anything, a dict, a list, a number, depending on the text it’s given, so its declared return type is Any: “I can’t know”. Values of type Any pass every check. More fundamentally, the type checker reads your code before it runs. The value "450" didn’t exist until Claude produced it, at runtime, on a Tuesday. No amount of reading code in advance can know what a model, a user or another company’s API will send tomorrow. For data from outside your program, you need a check that runs when the data arrives.

The receiving clerk: Pydantic

Pydantic is a library that turns a class full of type hints into a gatekeeper. You describe the shape you expect once; Pydantic checks every piece of incoming data against it at runtime. Where it safely can, it converts values to the right type. Where it can’t, it refuses with a clear, detailed report. Install it with uv add pydantic.

A Pydantic model looks like a dataclass, but inherits from BaseModel:

from typing import Literal

from pydantic import BaseModel, Field


class DraftOrder(BaseModel):
    item: str
    qty: int = Field(gt=0, le=50)                  # more than 0, at most 50
    price: int
    allergy_note: str | None = None                # optional, defaults to None
    pickup_day: Literal["Friday", "Saturday", "Sunday"]


reply = '{"item": "Plum cake", "qty": "2", "price": "450", "allergy_note": "no nuts", "pickup_day": "Saturday"}'
order = DraftOrder.model_validate_json(reply)

print(order)
print(type(order.price))
print(order.qty * order.price)
item='Plum cake' qty=2 price=450 allergy_note='no nuts' pickup_day='Saturday'
<class 'int'>
900

order.price is now a real int, and the total is ₹900. That one model_validate_json call replaced json.loads and every check Arjun would otherwise have scattered through his code.

Following one WhatsApp message through the clerk

Let’s slow down and trace what happened, field by field. Kabir’s message was: “Hi! 2 plum cakes for Saturday please, no nuts. Kabir”. Claude replied with this JSON text:

{"item": "Plum cake", "qty": "2", "price": "450", "allergy_note": "no nuts", "pickup_day": "Saturday"}

DraftOrder.model_validate_json(reply) does this:

Step Field Raw value Rule Result
1 (whole text) the JSON string Must be valid JSON Parsed into raw values
2 item "Plum cake" str Accepted as is
3 qty "2" (text) int, more than 0, at most 50 Text that is clearly a whole number is converted: 2; 0 < 2 ≤ 50 passes
4 price "450" (text) int Converted: 450
5 allergy_note "no nuts" str or None Accepted
6 pickup_day "Saturday" One of Friday, Saturday, Sunday Accepted
7 (all fields) Every required field present? Yes
8 A DraftOrder object, every field the right type

Now a worse reply. Suppose Claude had sent {"item": "Plum cake", "qty": "two", "price": "₹450", "pickup_day": "Tomorrow"}:

from typing import Literal

from pydantic import BaseModel, Field, ValidationError


class DraftOrder(BaseModel):
    item: str
    qty: int = Field(gt=0, le=50)
    price: int
    allergy_note: str | None = None
    pickup_day: Literal["Friday", "Saturday", "Sunday"]


bad = '{"item": "Plum cake", "qty": "two", "price": "₹450", "pickup_day": "Tomorrow"}'
try:
    DraftOrder.model_validate_json(bad)
except ValidationError as error:
    print(error.error_count(), "problems:")
    for problem in error.errors():
        print(" -", problem["loc"][0], "->", problem["msg"], "| got", repr(problem["input"]))
3 problems:
 - qty -> Input should be a valid integer, unable to parse string as an integer | got 'two'
 - price -> Input should be a valid integer, unable to parse string as an integer | got '₹450'
 - pickup_day -> Input should be 'Friday', 'Saturday' or 'Sunday' | got 'Tomorrow'

Pydantic doesn’t stop at the first problem. It checks every field and reports them all, each with the field name, the rule, and the value it received. (Printing error directly gives a longer version of the same report, with links to Pydantic’s docs.) This bad reply never becomes a draft order. Instead, the bot can flag it for a human, or, as you’ll do in the AI workflows lesson, send the error list back to the model and ask it to try again.

Opening the hood: validation, coercion and schemas

Two different jobs happen at the door, and it helps to name them:

  • Validation asks “is this value acceptable?”: is qty between 1 and 50? Is pickup_day one of the allowed days?
  • Coercion asks “can this value be safely converted?”: the text "2" becomes the number 2.

By default, Pydantic runs in lax mode: it coerces when the conversion is unambiguous, and refuses when it isn’t. Turning "2" into 2 is unambiguous. Turning "two" or "₹450" into a number isn’t, so Pydantic refuses rather than guess.

Note

Pause and think: Pydantic happily converts the text "2" into the integer 2. What do you think it does with the number 2.7 for the qty: int field? Round it to 3? Cut it to 2? Something else? Which would you want for a bakery order?

Show the answer

It refuses: Input should be a valid integer, got a number with a fractional part. Rounding or cutting would silently change the customer’s order, and 2.7 cakes is almost certainly a sign that something upstream went wrong. (It does accept 2.0, since nothing is lost.) This is the principle behind lax mode: convert only when no information is lost. If even "2" is too generous for your use, say for payment amounts, add strict=True to the field, as in Field(strict=True), or to the whole model, and Pydantic will accept only real integers.

Every model is also a schema

Here’s the part that makes Pydantic central to AI engineering. Every model can describe itself as a JSON Schema: a standard, machine-readable description of what valid JSON looks like.

import json
from typing import Literal

from pydantic import BaseModel, Field


class DraftOrder(BaseModel):
    item: str = Field(description="Menu item name, as written on Crumb's menu")
    qty: int = Field(gt=0, le=50)
    pickup_day: Literal["Friday", "Saturday", "Sunday"]


print(json.dumps(DraftOrder.model_json_schema(), indent=2))
{
  "properties": {
    "item": {
      "description": "Menu item name, as written on Crumb's menu",
      "title": "Item",
      "type": "string"
    },
    "qty": {
      "exclusiveMinimum": 0,
      "maximum": 50,
      "title": "Qty",
      "type": "integer"
    },
    "pickup_day": {
      "enum": [
        "Friday",
        "Saturday",
        "Sunday"
      ],
      "title": "Pickup Day",
      "type": "string"
    }
  },
  "required": [
    "item",
    "qty",
    "pickup_day"
  ],
  "title": "DraftOrder",
  "type": "object"
}

That schema is exactly what LLM APIs accept when you ask for structured output (a reply guaranteed to follow a given shape) and when you describe tools (functions a model can ask your code to run). One Python class gives you three things: the instructions you send the model, the check you run on its reply, and a typed object for the rest of your code. You’ll use this in Module 05 for structured outputs and Module 06 for function calling. The description= text ends up in the schema too, so the model reads it. Field descriptions are prompts.

Your own rules, nested models and going back out

Real rules go beyond types. A field validator is a method that runs your own check or clean-up on one field:

from pydantic import BaseModel, Field, ValidationError, field_validator

MENU = {"butter croissant": 85, "plum cake": 450, "kaju katli": 650}


class OrderLine(BaseModel):
    item: str
    qty: int = Field(gt=0, le=50)

    @field_validator("item")
    @classmethod
    def must_be_on_menu(cls, value: str) -> str:
        cleaned = value.strip().lower()
        if cleaned not in MENU:
            raise ValueError(f"{value!r} is not on the menu")
        return cleaned                     # what we return becomes the stored value


class Customer(BaseModel):
    name: str
    phone: str


class Order(BaseModel):
    customer: Customer | None = None       # a nested model, or None for guests
    lines: list[OrderLine]                 # a list of nested models

    def total(self) -> int:
        return sum(MENU[line.item] * line.qty for line in self.lines)


order = Order.model_validate({
    "customer": {"name": "Kabir", "phone": "+91-98xxxxxx04"},
    "lines": [{"item": " Plum Cake ", "qty": 2}, {"item": "Kaju katli", "qty": "1"}],
})
print(order.total())
print(order.model_dump())

try:
    Order.model_validate({"lines": [{"item": "Donut", "qty": 1}]})
except ValidationError as error:
    print(error.errors()[0]["loc"], error.errors()[0]["msg"])
1550
{'customer': {'name': 'Kabir', 'phone': '+91-98xxxxxx04'}, 'lines': [{'item': 'plum cake', 'qty': 2}, {'item': 'kaju katli', 'qty': 1}]}
('lines', 0, 'item') Value error, 'Donut' is not on the menu

What’s new here:

  • @field_validator("item") runs must_be_on_menu on every item. Raise ValueError to reject; return a value to accept (and optionally clean) it. " Plum Cake " was stored as "plum cake".
  • Models can contain models. Pydantic validates the whole tree, and the error’s location ('lines', 0, 'item') tells you exactly where the problem is: the first line’s item.
  • model_validate checks a Python dict (for example, one you already loaded); model_validate_json checks JSON text directly.
  • model_dump() turns the object back into plain dicts and lists, and model_dump_json() into JSON text, ready to save or send.

The front door and the back door: requests and responses

Pydantic earns its place at every boundary of your app, wherever data crosses from outside to inside, or back:

flowchart LR
    U["Customer request (JSON)"] --> R["Request model"]
    R --> A["Your app logic"]
    A --> L["LLM call"]
    L --> M["LLM reply model"]
    M --> A
    A --> S["Response model"]
    S --> O["JSON back to the website"]
  • Requests coming into your API: a request model checks what the website or app sent. FastAPI, the web framework you’ll use in Module 05, does this automatically: declare a parameter as a Pydantic model, and invalid requests are rejected with a helpful error before your code even runs.
  • Replies from an LLM or another API: validate them before trusting a single field.
  • Responses going back out: a response model guarantees you never send the website a half-built object, or accidentally include a field like internal_cost.

Inside the boundary, between your own functions, plain type hints plus a type checker are usually enough. Validating the same data five times as it moves through your own code adds noise without adding safety.

Why this matters more in AI apps

Traditional APIs are built by engineers to follow a specification. LLMs are not. They produce text that is almost always the right shape, and when it isn’t, it’s wrong in creative ways:

  • Numbers as text: "450", or "₹450", or "450.00".
  • Renamed fields: quantity instead of qty.
  • Category values that don’t exist: "refund_request" instead of "refund".
  • Friendly extras: “Sure! Here’s the JSON you asked for:” before the actual JSON.
  • Missing fields, when the message didn’t mention them.

A test with ten messages passes. Production, with ten thousand messages a week, finds every one of these. Structured output features (Module 05) make most of them much rarer, but “rarer” isn’t “never”, so a Pydantic model at the door remains the safety net. The rule experienced AI engineers follow: never pass model output deeper into your system without validating it.

Where it breaks

  • “Optional” doesn’t mean “can be left out”. In Pydantic, note: str | None with no default means “required, but may be None”. Leave it out and you get Field required. Write note: str | None = None to make it truly optional. This surprises nearly everyone once.
  • Lax mode is too lax for some fields. Coercing "2" to 2 is helpful for quantities. For money or IDs, you may prefer strict=True and an explicit error.
  • Validating late. If raw dicts travel through five functions before reaching a model, bugs happen in those five functions first. Validate at the door.
  • Any switches off the inspector. A function that returns Any (like json.loads) or takes dict with no detail lets mistakes through the type checker silently. Add precise hints, or validate into a model as soon as possible.
  • Hints that lie. A hint that says -> int on a function that sometimes returns None is worse than no hint, because people trust it. Keep hints true, and let the type checker hold you to it.

Arjun explains it to Meera

Arjun deploys the fix: Claude’s reply now goes through DraftOrder before anything else touches it. That week, 1,184 WhatsApp messages become draft orders. Nine are rejected at the door, all with clear reasons (“pickup_day: got ‘tomorrow’”), and sent to staff to confirm by hand. Zero invoices for four lakh rupees.

“So what was wrong?” Meera asks.

“Claude wrote the price in quotes, so Python treated it as text, and text times two is the text written twice,” Arjun says. “I’d labelled the function to say ‘price is a number’, but those labels are notes for people and tools. Python doesn’t check them while it runs. Now every reply from Claude goes through a checker first, Pydantic, which knows exactly what an order must look like. It fixes what’s safe to fix, like ‘450’ in quotes. Anything it can’t be sure about, like ‘two’ or ‘tomorrow’, it rejects, and a person looks at it.”

“And the same checker tells Claude what we want?”

“The same description, yes. One definition: what we ask for, and what we accept.”

Try it yourself

Crumb’s inbox bot asks a model to classify support emails. Write a Pydantic model TicketTriage with:

  • category: one of "late_delivery", "refund", "allergy", "other"
  • order_id: optional (default None), but when present it must match the pattern CR- followed by four digits. Use Field(pattern=r"^CR-\d{4}$").
  • urgency: an integer from 1 to 5
  • summary: a string of at most 80 characters (Field(max_length=80))

Then validate these three model replies and print either OK with the object, or the number of problems and the fields involved:

replies = [
    '{"category": "allergy", "order_id": "CR-2210", "urgency": "5", "summary": "Asks if plum cake has walnuts"}',
    '{"category": "refund_request", "order_id": "2210", "urgency": 9, "summary": "Wants money back"}',
    '{"category": "late_delivery", "urgency": 3, "summary": "Rider 40 minutes late"}',
]

Expected output:

OK category='allergy' order_id='CR-2210' urgency=5 summary='Asks if plum cake has walnuts'
3 problems: ['category', 'order_id', 'urgency']
OK category='late_delivery' order_id=None urgency=3 summary='Rider 40 minutes late'
Show the answer
from typing import Literal

from pydantic import BaseModel, Field, ValidationError


class TicketTriage(BaseModel):
    category: Literal["late_delivery", "refund", "allergy", "other"]
    order_id: str | None = Field(default=None, pattern=r"^CR-\d{4}$")
    urgency: int = Field(ge=1, le=5)
    summary: str = Field(max_length=80)


replies = [
    '{"category": "allergy", "order_id": "CR-2210", "urgency": "5", "summary": "Asks if plum cake has walnuts"}',
    '{"category": "refund_request", "order_id": "2210", "urgency": 9, "summary": "Wants money back"}',
    '{"category": "late_delivery", "urgency": 3, "summary": "Rider 40 minutes late"}',
]

for reply in replies:
    try:
        triage = TicketTriage.model_validate_json(reply)
        print("OK", triage)
    except ValidationError as error:
        fields = [problem["loc"][0] for problem in error.errors()]
        print(f"{error.error_count()} problems: {fields}")
OK category='allergy' order_id='CR-2210' urgency=5 summary='Asks if plum cake has walnuts'
3 problems: ['category', 'order_id', 'urgency']
OK category='late_delivery' order_id=None urgency=3 summary='Rider 40 minutes late'

The second reply shows the creative ways models go wrong: an invented category (refund_request), an ID without its prefix, and an urgency off the scale. The third reply leaves out order_id entirely, which is fine because it has a default. ge means “greater than or equal to”; gt means “strictly greater than”.

Common mistakes

  • Believing hints are enforced. They aren’t, at runtime. Use a type checker for your own code, and Pydantic for outside data.
  • Using json.loads and then trusting the dict. Validate straight from the text with Model.model_validate_json(text).
  • Catching ValidationError and ignoring it. The error list is valuable: log it, show it to staff, or send it back to the model for a retry.
  • Forgetting = None on optional fields. Without a default, the field is still required.
  • Mixing up dataclasses and Pydantic models. Dataclasses are for data you create yourself inside your program; Pydantic models are for data arriving from outside, which needs checking.

Recap

  • Type hints (price: int, -> int, list[str], str | None, Literal[...]) describe what code expects; Python ignores them at runtime.
  • Type checkers like mypy and Pyright read hints before your code runs and catch mismatches in your own code, but can’t see data that only exists at runtime.
  • Pydantic models check outside data when it arrives: they coerce values only when nothing is lost, reject the rest, and report every problem with its location.
  • Field(...) adds rules, @field_validator adds your own, models nest, and model_dump and model_dump_json turn objects back into plain data.
  • Every Pydantic model is also a JSON Schema, which is how you’ll tell LLMs what shape to reply in. Never trust model output without validating it.

The draft-order feature is a hit, and staff start forwarding everything to the bot. Monday morning brings 400 emails from the weekend. Arjun’s script classifies them one at a time, three seconds each, and it’s still running at 9:20 when the support team arrives. “Your code spends most of its time waiting,” Priya says, watching the progress bar crawl. “Next, let’s teach it to wait for many things at once.”

Go deeper

  • Pydantic documentation: models, fields, validators and JSON Schema.
  • The typing module, the reference for every kind of type hint.
  • FastAPI, to see Pydantic models used for request and response validation (you’ll build with it in Module 05).

Check your understanding

3 quick questions
1. def line_total(price: int, qty: int) -> int is called with price="450" and qty=2. What happens when the code runs?
2. A Pydantic model has qty: int. What does it do with the input "2", and with "two"?
3. Why are Pydantic models so useful when working with LLMs?