Search Tech Journey

Find topics, journeys and posts

6-month learning plan14 / 130
back to blog
pythonbeginner 15m read

S014 · Type Hints, mypy, dataclasses & pydantic

Modern Python that scales past 1000 lines.

Module M01: Python Foundations · Session 14 of 130 · Track: SW 🏷️

What you'll be able to do after this session

  • Explain Type Hints, mypy, dataclasses & pydantic to a friend in one minute, out loud, no notes.
  • Recognise it when you see it in real code / systems / papers.
  • Complete the hands-on exercise at the end without looking up help.

Prerequisites

If you can't answer these in 30 seconds each, redo the linked session first:


Pre-read (skim before the session)


(a) Intuition · 5 min

The one-paragraph mental model. Why this concept exists, what problem it solves, and the analogy that makes it stick.

In one sentence: Modern Python that scales past 1000 lines.

Python without type hints is like a warehouse where every box is unlabelled — you have to open each one to know what's inside. That's fine for 100 lines. At 10,000 lines, you spend more time opening boxes than doing work. Type hints are the labels. def get_user(id: int) -> User | None: tells you and every tool that this function takes an int and returns either a User or None. Your editor now autocompletes .name on the result, mypy screams if you pass a string, and future-you thanks past-you.

dataclasses are a decorator that generates __init__, __repr__, and __eq__ from a class body of annotations — replacing 30 lines of boilerplate with 3. pydantic goes further: it also validates the data at runtime and can parse from JSON automatically. dataclasses are internal contracts; pydantic is what you use at system boundaries (API request bodies, config files, LLM responses).

The forever-gotcha: type hints are not enforced at runtime (dataclasses ignore them; pydantic enforces them). Passing a string to def f(x: int) runs fine unless mypy or pydantic checks it. Types are documentation + a promise to your tools, not a runtime guard — unless you opt in.


(b) Visual walkthrough · 15 min

Diagrams, worked examples, step-by-step visuals.

Where each tool fits in the layered picture:

Rule of thumb:

  • Inside your codebase (function signatures, return types): plain hints + mypy.
  • Internal data containers (a bag of related fields, no validation needed): @dataclass.
  • External boundaries (HTTP requests, config files, LLM JSON output): pydantic.BaseModel.

Type-hint vocabulary you must recognise:

HintMeaning
int, str, boolprimitives
list[int]a list of ints (Python 3.9+; before: List[int])
dict[str, int]a mapping
int | None or Optional[int]may be missing
Literal["r", "w"]must be exactly one of these values
Callable[[int, int], int]a function taking two ints, returning int
TypeVar("T") / generics"same type in and out"
Protocolstructural typing — "anything with .read()"

Worked example — dataclass vs pydantic:

from dataclasses import dataclass
from pydantic import BaseModel, Field
 
@dataclass
class UserInternal:
    id: int
    email: str
    age: int = 0
 
class UserAPI(BaseModel):
    id: int
    email: str = Field(pattern=r".+@.+")
    age: int = Field(ge=0, le=120)
 
UserInternal(id="bad", email="x", age=-5)      # ← runs fine!  Types not enforced.
UserAPI(id="bad", email="x", age=-5)           # ← raises ValidationError with 3 problems
UserAPI.model_validate_json('{"id":1,"email":"a@b","age":30}')  # parse+validate from JSON

Notice the three failures pydantic surfaces in one error object — vs the mystery AttributeError you'd get later from the dataclass.

mypy in 30 seconds — install (uv add --dev mypy), run (uv run mypy src/), read output:

src/app.py:12: error: Argument 1 to "get_user" has incompatible type "str"; expected "int"

Line-and-column-precise, before you ever run the code.


(c) Hands-on · 20 min

Code you type and run. Not pseudo-code — real, runnable, copy-pasteable.

mkdir typed-demo && cd typed-demo && uv init --package
uv add pydantic
uv add --dev mypy pytest

Create src/typed_demo/models.py:

"""Two containers: one internal (dataclass), one external (pydantic)."""
from dataclasses import dataclass, field
from datetime import datetime
from typing import Literal
 
from pydantic import BaseModel, EmailStr, Field, field_validator
 
 
# ---- internal: cheap bag of fields ---------------------------------
@dataclass(slots=True, frozen=True)
class OrderLine:
    sku: str
    qty: int
    unit_price_cents: int
 
    @property
    def subtotal_cents(self) -> int:
        return self.qty * self.unit_price_cents
 
 
# ---- external: validated at the boundary ---------------------------
class CreateOrderRequest(BaseModel):
    customer_email: EmailStr
    currency: Literal["USD", "EUR", "INR"] = "USD"
    lines: list[dict] = Field(min_length=1, max_length=100)
    placed_at: datetime = Field(default_factory=datetime.utcnow)
 
    @field_validator("lines")
    @classmethod
    def _no_zero_qty(cls, v: list[dict]) -> list[dict]:
        for i, line in enumerate(v):
            if line.get("qty", 0) <= 0:
                raise ValueError(f"line {i}: qty must be positive")
        return v
 
 
def total_cents(lines: list[OrderLine]) -> int:
    return sum(l.subtotal_cents for l in lines)

src/typed_demo/main.py:

import json
from .models import CreateOrderRequest, OrderLine, total_cents
 
RAW = """
{"customer_email":"alice@example.com","currency":"USD",
 "lines":[{"sku":"A1","qty":2,"unit_price_cents":500},
          {"sku":"B2","qty":1,"unit_price_cents":1299}]}
"""
 
def run() -> None:
    req = CreateOrderRequest.model_validate_json(RAW)   # ← parse + validate
    print("validated:", req)
    lines = [OrderLine(**l) for l in req.lines]
    print(f"total = ${total_cents(lines)/100:.2f}")
 
if __name__ == "__main__":
    run()

Run everything:

uv run python -m typed_demo.main
uv run mypy src/

Observe when you run:

  1. The JSON parses into a CreateOrderRequest with placed_at auto-filled.
  2. Total prints $22.99.
  3. mypy prints Success: no issues found. Change qty: int on OrderLine to qty: str and rerun — mypy flags the multiplication.
  4. Corrupt the JSON: change "qty":2 to "qty":-5 — pydantic raises a ValidationError listing the exact path (lines.0.qty).
  5. Try passing an invalid email ("alice"); pydantic tells you which regex failed and where.

Try this modification: add a discount_pct: float = Field(ge=0, le=100, default=0) to CreateOrderRequest and a total_after_discount() free function. Add a mypy-checked test that asserts a 10% discount on the RAW example returns 2069 cents.


(d) Production reality · 10 min

How this shows up in real systems, gotchas, war stories, common bugs.

Dropbox's engineering blog is the canonical war story: they annotated 4 million lines of Python over three years, caught thousands of real bugs during rollout, and dropped runtime crashes materially. They shipped tools (monkeytype, pyannotate) that watch production and suggest type hints from observed calls — an approach worth stealing.

pydantic is now the schema layer under FastAPI, LangChain, HuggingFace transformers pipelines, and half the LLM ecosystem — because every LLM call is an untrusted JSON string that needs parsing + validation before it touches your business logic. At Microsoft on OneNote Copilot, every LLM response is parsed into a pydantic model with Field(strict=True) — if the model hallucinates a field, we catch it at the boundary and retry, instead of a KeyError five layers deep.

Common bugs teams hit:

  • Optional[X] vs X | None — semantically identical, but Optional[X] is confusing because it doesn't mean "optional argument" (that's = None), it means "may be None". Use X | None in Python 3.10+.
  • Mutable default argumentsdef f(x: list = []) is the classic footgun. dataclass handles it via field(default_factory=list); pydantic via Field(default_factory=list).
  • Any as a truth serum — one Any in the middle of a call chain silently disables type-checking downstream. Turn on mypy --strict and --disallow-any-explicit in CI.
  • pydantic v1 vs v2 — v2 is a full rewrite in Rust, 5-50× faster, but has API changes (.dict().model_dump(), @validator@field_validator). Always check which major version a tutorial targets.

At Netflix, every service boundary uses pydantic models that are also the source of truth for OpenAPI docs — one schema, three uses (validation, docs, client codegen).

Perf tip: for hot paths, dataclass(slots=True, frozen=True) is ~30% faster than a regular class and hashable — good for dict keys. Pydantic v2 is fast enough for API boundaries but don't put it inside a for loop over millions of rows; use dataclasses or plain tuples there.


(e) Quiz + exercise · 10 min

  1. Are Python type hints enforced at runtime? What happens if you pass the wrong type?
  2. When would you reach for a dataclass vs a pydantic.BaseModel?
  3. What does mypy do that pytest cannot, and vice versa?
  4. What's the difference between Optional[int], int | None, and int = None in a signature?
  5. Give an example of a validation constraint you can express in pydantic that a dataclass cannot.

Stretch (connects to S013 Testing): write a pytest test that uses pytest.raises(pydantic.ValidationError) and asserts the error contains exactly the three fields you expect to be invalid (email, currency, qty). Use exc_info.value.errors() to inspect the list.


Explain-out-loud test

If you can't teach these 3 things to a friend without notes, redo the session:

  1. What is Type Hints, mypy, dataclasses & pydantic? (one sentence, no jargon)
  2. When would you use it? (one concrete example)
  3. When would you NOT use it? (one anti-pattern)

What comes next


Part of a 130-session evergreen learning series. Session structure: (a) intuition · (b) visual walkthrough · (c) hands-on · (d) production reality · (e) quiz + exercise. Duration: 60 minutes.

More from M01 · Python Foundations

all modules →
  1. 005Python Variables & Types — Mental Model of Memory
  2. 006Control Flow — if/else, loops, comprehensions
  3. 007Functions — arguments, scope, closures
  4. 008Data Structures — list, tuple, dict, set (when to use what)
  5. 009Classes & Objects — the OOP Mental Model
  6. 010Inheritance, Composition & Polymorphism