Type Hints, mypy, dataclasses & pydantic
Distinguish static annotations, shallow frozen dataclasses and runtime validation; apply typed interfaces and explicit input contracts.
Original publication date unrecordedUpdated
Lesson preparation & details
Level: beginner
By the end, you should be able to
- Annotations are not runtime validation
Bring with you
Sources checked: . A source check does not change the publication or update date.
Editorial review: · What review means
In this article · 25 sections
Review boundary and corrected contracts
Annotations are not runtime validation. Python 3.14 defers ordinary annotation evaluation; details are version-sensitive. Frozen dataclasses are shallow and hashable only when participating fields are hashable. Decimal has finite context precision and rounding; mypy has no --warn-only option.
The examples below teach the stated input contracts; fragments and deliberately incorrect counterexamples are exercises, not production implementations. Complexity counts elementary operations unless a bit-cost or allocation caveat is stated. Practice times are suggestions, not measured performance or hiring guarantees.
Learning objective: Refactor a dict-shaped codebase into typed dataclasses and pydantic models, run mypy in strict mode without warnings, and know exactly when to reach for each.
Why this session exists
As Python code gains callers and collaborators, undeclared type assumptions become harder to track. For example, a bug can arise when user_id is sometimes a string and sometimes an int; three months later a stranger tries to add a feature, spends 20 minutes tracing dict["user"]["profile"]["prefs"] through five files, and gives up. Types are how you make a codebase teachable to strangers (including future-you). This session teaches you the three flavours modern Python offers — hints for a separate static checker, dataclasses for internal value objects, and pydantic for the fuzzy boundary where data enters your system.
You will be able to
-
Read a signature like
def f(xs: list[User]) -> dict[str, int] | None:fluently. -
Add type hints to an untyped function and run mypy without noise.
-
Choose between
dict,TypedDict,dataclass,pydantic.BaseModel, andNamedTuple— and defend the choice. -
Validate incoming JSON with pydantic v2 and get precise error messages for free.
-
Enable strict-ish mypy on a real project without drowning in errors.
Prerequisites
- S012 · Modules & Packaging — you'll add mypy/pydantic to a pyproject.toml.
- S013 · pytest — you'll type-check your test suite too.
- Comfortable writing classes, list/dict comprehensions, and using
dict[str, Any]in anger.
Pre-read · skim before the session (30 min total)
Further reading: typing — Support for type hints
Further reading: Pydantic docs · Concepts
Further reading: mypy · Getting Started + Cheat Sheet
Optional archived videos
These are retained original resource URLs, not independently verified titles, speakers, durations or technical evidence. Use the official readings above for API contracts.
- Original video 1 — optional background; availability and attribution unverified.
- Original video 2 — optional background; availability and attribution unverified.
- Original video 3 — optional background; availability and attribution unverified.
(a) Intuition · 5 min
Types are diagrams a configured checker checks
Real: Imagine a factory floor. Every conveyor belt is labelled: bolts here, washers here, finished assemblies out this end. If someone dumps screws on the bolt conveyor, a machine beeps immediately — long before the wrong part reaches the finished-assembly line. Without labels, the same mistake reaches the customer three weeks later. The labels are cheap. The rework isn't.
Type hints are those labels. def area(r: float) -> float: says "this belt carries floats in, floats out." mypy is the machine that beeps when someone passes a string. Python does not enforce ordinary annotations on calls, but annotation evaluation/introspection can have runtime effects. A configured checker can report mismatches in the code it analyses.
The trick is gradual: you don't have to type everything. Type the boundaries (function signatures, class attributes, public APIs). Leave the two-line comprehensions alone. Types earn their keep on the interfaces, not the internals.
Three flavours, three jobs
Which typing tool for which job
-
Type hints + mypy — a static check that runs on your codebase. The checker runs separately; it catches some type mismatches without executing the application. Annotation evaluation and validation are separate runtime concerns.
-
dataclasses — a stdlib decorator that generates __init__, __repr__, __eq__ for a value object. For data you own and trust internally.
-
pydantic — runtime validation of untrusted input. For the fuzzy boundary: JSON from an HTTP request, YAML config, LLM output. It parses AND validates AND coerces.
The one confusion that trips everyone up
Ordinary annotations do not validate arguments. An annotated function can accept a wrong type and either return an unintended value or fail later. mypy reports a mismatch only when the relevant code is checked with enough type information; Any, ignored errors and unchecked code weaken this. Explicit validation supplies runtime checks.
A quick history
PEP 484 introduced the type-hint framework for Python 3.5; annotations were not made runtime argument checks. dataclasses joined the stdlib in 3.7, generating common methods without replacing every use of named tuples or attrs. Pydantic v2 was released in 2023 with a redesigned validation engine; a speedup must be measured on the actual model. Modern schemas can inform APIs and tooling, but neither a schema nor a checker is a proof of business correctness. Unsupported adoption percentages and incident-elimination claims from the older timeline are not historical evidence.
(b) Separating boundary validation from internal typing · 15 min
How the pieces fit — a request/response lifecycle
Type hints, dataclasses, and validators share syntax but do different jobs. Follow incoming JSON through the validation decision before it reaches the typed core, keeping runtime checks separate from the checker’s analysis of your source.
Figure text equivalent — labels and relationships
- Node “Untrusted JSON from browser” (square).
- Node “pydantic BaseModel.model_validate(...)” (square).
- Node “Typed dataclass or plain object flows through your code” (square).
- Node “ValidationError with field-level messages; application maps response” (square).
- Node “Type-hinted business logic mypy checks analysed source under its configured assumptions” (square).
- Node “Response BaseModel → JSON out” (square).
- “Untrusted JSON from browser” leads to “pydantic BaseModel.model_validate(...)”. Line: normal; ends: arrow point.
- “pydantic BaseModel.model_validate(...)” leads to “Typed dataclass or plain object flows through your code”: valid. Line: normal; ends: arrow point.
- “pydantic BaseModel.model_validate(...)” leads to “ValidationError with field-level messages; application maps response”: invalid. Line: normal; ends: arrow point.
- “Typed dataclass or plain object flows through your code” leads to “Type-hinted business logic mypy checks analysed source under its configured assumptions”. Line: normal; ends: arrow point.
- “Type-hinted business logic mypy checks analysed source under its configured assumptions” leads to “Response BaseModel → JSON out”. Line: normal; ends: arrow point.
The invalid branch raises ValidationError; Pydantic itself does not send an HTTP 422 response. Framework or application error handling decides the transport response. The valid branch can use ordinary Python objects internally: a dataclass organises those values, while mypy checks how the code uses their declared types. Neither replaces validation of incoming data.
The typing tools you'll actually use
The Python typing stack, layered
Name: Primitives — int, str, bool, float, bytes, None
Desc: Just Python types. Union with |: int | None. Since 3.10.
Name: Generics — list[X], dict[K, V], tuple[X, ...]
Desc: Since 3.9 you can lowercase them. dict[str, list[int]] — reads left to right.
Name: Literal, Final, ClassVar, TypeAlias
Desc: Literal['red','blue'] restricts values. Final tells a static checker to reject reassignment; Python does not enforce it at runtime. Nice for enums-lite and constants.
Name: TypedDict — schema for dicts
Desc: When JSON-ish data must stay a dict. Structural typing without instantiating a class.
Name: Protocol — structural interfaces
Desc: A Protocol declares required operations; an object can satisfy them structurally without inheriting the protocol. The complete Readable example below uses a text-returning read method.
Name: dataclasses — value objects
Desc: @dataclass(frozen=True, slots=True) generates init/repr/eq, blocks ordinary field assignment and restricts layout. Freezing is shallow; measure memory and speed for the actual class.
Name: pydantic BaseModel — runtime validation
Desc: Same hint syntax; parses, validates, coerces at runtime. model_dump_json() serialises values; model_json_schema() describes the schema. They are different outputs.
When to reach for what
Name: plain dict
Tagline: Throwaway scripts
-
Zero ceremony
-
No declared field schema unless separately annotated
-
IDE support depends on inferred/declared key and value types
-
Fine for a 20-line pipeline
-
Also useful for genuinely dynamic mappings in libraries
Name: TypedDict
Tagline: Dict shape matters, but stays a dict
-
Static-only (mypy)
-
No runtime cost
-
Interop with JSON APIs
-
Compose with
NotRequired -
Great for JSON-ish payloads you don't own
Name: dataclass
Tagline: Internal value objects
-
__init__,__repr__,__eq__free -
frozen=Truefor shallow assignment protection; generated hashing still needs hashable fields -
slots=Truerestricts layout; performance is workload-dependent -
No automatic field-type validation; explicit
__post_init__checks are possible -
Use for domain entities you own
Name: pydantic BaseModel
Tagline: Untrusted boundaries
-
Parses AND validates AND coerces
-
Field-level errors
-
JSON-schema for free (OpenAPI, LLM tools)
-
v2 has a redesigned validation engine; no universal per-model timing follows
-
Use for HTTP, config, LLM output, DB seed data
NamedTuple is another choice: a typed tuple subclass with named fields and positional unpacking. Fields cannot be rebound, but referenced mutable values remain mutable. Use it when tuple compatibility is part of the interface; use a dataclass for a record whose methods/defaults/evolution matter more than positional sequence behaviour. A TypedDict remains an ordinary dictionary at runtime, so none of these choices validates incoming JSON automatically.
from typing import NamedTuple, TypedDict, NotRequired
class Point(NamedTuple):
x: int
y: int
class UserPayload(TypedDict):
id: int
nickname: NotRequired[str]
point = Point(2, 3)
x, y = point
payload: UserPayload = {"id": 7}
assert (x, y, payload["id"]) == (2, 3, 7)A readable object and a file path are distinct alternatives. Narrow the union before choosing an operation; the protocol deliberately requires only the method the function uses.
from typing import Protocol
class Readable(Protocol):
def read(self) -> str: ...
def read_text(source: str | Readable) -> str:
if isinstance(source, str):
with open(source, encoding="utf-8") as stream:
return stream.read()
return source.read()The mypy workflow in 4 steps
uv add --dev mypy
Desc: Install as a dev dependency. Not needed at runtime, only during development and CI.
Add [tool.mypy] to pyproject.toml
Desc: Enable strict-ish mode: strict = true or a curated subset (see hands-on).
mypy src/
Desc: Run against your source tree and classify the diagnostics. Their count depends on the code, dependencies and checker configuration; no normal error count is assumed.
Fix or # type: ignore[reason]
Desc: Fix genuine bugs. Escape hatch: # type: ignore[assignment] with a specific error code — never bare.
Mental model
Types on boundaries, dataclasses inside, pydantic at the door
The rule of thumb that survives contact with real projects: pydantic at every place where data enters or leaves your process (HTTP, config, LLM, CLI args). Dataclasses for the pure-Python values that flow between your functions. Type hints + mypy everywhere in between. That's it.
Myth: "Type hints are enforced at runtime. If I annotate def f(x: int) and pass a string, Python will raise a TypeError — that's the point of adding them."
Annotations do not validate ordinary calls. Python 3.14 defers annotation evaluation by default; older versions and future annotations have different storage/evaluation rules. A type checker or explicit validator supplies enforcement.
Why: Because the syntax is identical to languages where it is enforced, and because in a well-typed codebase the checker catches your mistakes before you run anything — so it feels like the runtime did it. The gap is invisible until data arrives from outside your program. A function annotated -> dict[str, int] that parses JSON will happily return strings, because mypy checked your code and JSON came from the network. This is exactly why the boundary layer needs real validation rather than annotations: static checking reports inconsistencies it can see; unchecked functions, Any, casts and incomplete stubs prevent a blanket guarantee. External data still needs validation.
Test: annotation syntax is not a runtime call check. The deliberately mistyped calls below run in Python and should be rejected by a checker when analysed.
from dataclasses import dataclass
def f(x: int) -> int:
return x * 2
print(f("ab")) # "abab" at runtime; static argument error
@dataclass
class Row:
id: int
print(Row(id="oops")) # runtime accepts it; static argument errorQuestion: Why does an ordinary @dataclass with default equality disable hashing, while frozen=True generates a hash that may still fail when called?
Premise: A hash table requires that equal objects have equal hashes, or lookup breaks: you would store under one bucket and search another.
Because: the hash decides the bucket, and equality decides the match within it
Premise: A plain object's default equality is identity, and its default hash is derived from identity. Those are trivially consistent — two objects are equal only when they are the same object.
Because: identity is stable for an object's lifetime, so both derived properties are stable
Premise: A dataclass replaces equality with structural comparison: two distinct objects with the same field values are now equal.
Because: value semantics is the entire reason to use a dataclass
Premise: But the inherited identity-based hash would give those two equal objects different hashes, violating the invariant — a silent, near-undebuggable failure where a dict lookup misses a key that compares equal to one it contains.
Because: the two defaults are no longer derived from the same thing
Premise: A generated field-based hash must remain stable and agree for equal objects. Mutating hash-participating values can break that invariant after insertion. A custom class could instead choose another consistent stable hash; generating one from mutable value fields is unsafe.
Because: nothing re-indexes a dict when an object mutates; the table is never notified
Conclusion: The default dataclass policy with eq=True and frozen=False sets __hash__ = None, avoiding an automatically generated mutable-field hash. This is a conservative default, not a proof that no custom mutable class could ever be hashable. With default eq=True and frozen=True, dataclasses generate a field-based hash and block ordinary assignment. This does not guarantee deep immutability or hashability of participating fields. Note what this predicts: writing your own __eq__ on any class silently drops __hash__ unless you define it too, so instances suddenly stop working as dict keys or set members. It also predicts that frozen=True is only shallow — a frozen dataclass holding a list field is still unhashable at hash time, exactly as hash((1, [2])) fails.
Name: Static checks constrain code; validators inspect data
Picture: A checker follows declared/inferred types through analysed code. A value typed int | None must be narrowed before arithmetic, but Any can bypass that check. At runtime a validator inspects values that actually arrive and enforces only the configured schema. Neither proves authorization, freshness, accounting rules or absence of logic errors. Use tests for those behaviours and explicit checks where trust or state changes.
-
Annotate function boundaries and dataclass fields; skip obvious locals. The payoff is in signatures, where callers read them.
-
Optional[X]meansX | None; under strict optional checking, narrow the value before using X-only operations. It does not mean an argument can be omitted. -
Use frozen records when rebinding fields is unwanted. Lists inside remain mutable, and thread safety requires reasoning about the referenced state;
frozen=Truealone does not supply it. -
Parse into validated values at the trust boundary, then pass those values through typed interfaces. Recheck invariants after mutation, state changes or another untrusted entry point; annotations do not preserve a runtime guarantee by themselves.
Trigger: Fire this model the moment you see: AttributeError: 'NoneType' object has no attribute ... · unhashable type on your own class · a mutable default in a dataclass field · Any spreading through a module · a function whose docstring describes types the signature doesn't.
Decision: How much type discipline does a codebase buy — none, gradual hints checked in CI, or runtime-validated models everywhere?
Name: No annotations
Gain: maximum flexibility and no tooling; duck typing lets you pass anything with the right methods, which is genuinely the fastest way to write exploratory code
Cost: type expectations may be less explicit; reviewers rely more on tests, documentation and implementation reading, and IDE inference can be incomplete
Pick: notebooks, throwaway analysis, and code with a lifetime measured in hours — the exploratory phase, honestly labelled
Name: Gradual hints + mypy/pyright in CI
Gain: some type mismatches caught in analysed code without running the application, plus signatures that document themselves and IDEs that actually help; adoptable file by file
Cost: a real learning curve at the edges (generics, variance, protocols); third-party stubs are uneven; and a mostly-typed codebase invites Any escape hatches that quietly erase the guarantee
Pick: the code will be maintained by more than one person for more than a few months — which is the default for anything in a repo
Name: Runtime validation (pydantic / attrs validators)
Gain: configured constraints checked against real data, with coercion, structured errors and JSON schema generation; explicit custom checks or vectorised schema validators are alternatives at other boundaries
Cost: per-object construction cost, which is material in a hot loop over millions of rows; a second type vocabulary alongside the static one; and it can tempt you into revalidating already-checked values while overlooking mutable state or unvalidated entry points
Pick: the data crosses a trust boundary — API request bodies, config files, message payloads, third-party responses
Verdict: The combination is the answer, applied by layer rather than uniformly: validated models at the boundary, plain typed dataclasses inside, checker in CI over both. That separates schema failures from static diagnostics and behavioural tests. For high-volume data work: per-row validation of a hundred million rows is the wrong shape entirely. Use vectorised constraints and aggregate checks where suitable, but validate row-level invariants as well: matching a column schema cannot prove every value is valid. Types are for code correctness; column-level assertions are for data correctness, and the volume is what decides which tool applies.
(c) Hands-on · 25 min
You'll refactor an untyped "orders" module into a typed, dataclass-based core with a pydantic boundary — then run mypy for static diagnostics and pytest for selected runtime behaviours. Neither proves correctness. Save as type_demo.sh.
#!/usr/bin/env bash
# type_demo.sh — from dict soup to typed value objects.
set -euo pipefail
WORK_DIR="$HOME/projects/learning/s014/orders"
log() { printf "\033[1;36m▶ %s\033[0m\n" "$*"; }
log "1/6 Fresh project"
WORK_DIR=$(mktemp -d "${TMPDIR:-/tmp}/typing-workshop.XXXXXX")
mkdir -p "$WORK_DIR/src/orders" "$WORK_DIR/tests"
cd "$WORK_DIR"
touch tests/__init__.py # makes the tests.* mypy override unambiguous
log "2/6 pyproject with mypy + pydantic + pytest"
cat > pyproject.toml <<'TOML'
[project]
name = "orders"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = ["pydantic>=2.5,<3"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/orders"]
[tool.mypy]
python_version = "3.11"
strict = true
warn_unused_ignores = true
warn_redundant_casts = true
# Common escape hatches for a real codebase:
[[tool.mypy.overrides]]
module = ["tests.*"]
disallow_untyped_defs = false
TOML
log "3/6 Domain: typed dataclasses for internal use"
cat > src/orders/__init__.py <<'PY'
from .domain import Order, LineItem, OrderStatus, order_total
from .api import CreateOrderRequest, OrderResponse
__all__ = [
"Order", "LineItem", "OrderStatus", "order_total",
"CreateOrderRequest", "OrderResponse",
]
PY
cat > src/orders/domain.py <<'PY'
"""Internal domain — values we trust because we constructed them."""
from __future__ import annotations
from dataclasses import dataclass, field
from decimal import Decimal
from enum import StrEnum
from uuid import UUID, uuid4
class OrderStatus(StrEnum):
PENDING = "pending"
PAID = "paid"
SHIPPED = "shipped"
CANCELLED = "cancelled"
@dataclass(frozen=True, slots=True)
class LineItem:
sku: str
quantity: int
unit_price: Decimal
@property
def subtotal(self) -> Decimal:
return self.unit_price * self.quantity
@dataclass(slots=True)
class Order:
customer_id: UUID
items: list[LineItem]
id: UUID = field(default_factory=uuid4)
status: OrderStatus = OrderStatus.PENDING
def order_total(order: Order) -> Decimal:
"""Sum every line's subtotal. Pure, testable, typed."""
return sum((item.subtotal for item in order.items), start=Decimal("0"))
PY
log "4/6 Boundary: pydantic validates the JSON that arrives"
cat > src/orders/api.py <<'PY'
"""Boundary types — validated at the edge of the process."""
from __future__ import annotations
from decimal import Decimal
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field, field_validator
from .domain import LineItem, Order, OrderStatus, order_total
class LineItemPayload(BaseModel):
model_config = ConfigDict(extra="forbid")
sku: str = Field(min_length=1, max_length=64)
quantity: int = Field(gt=0, le=1000)
unit_price: Decimal = Field(gt=0, max_digits=10, decimal_places=2)
class CreateOrderRequest(BaseModel):
"""Validate declared fields and reject extra input keys."""
model_config = ConfigDict(extra="forbid")
customer_id: UUID
items: list[LineItemPayload] = Field(min_length=1)
@field_validator("items")
@classmethod
def _unique_skus(cls, v: list[LineItemPayload]) -> list[LineItemPayload]:
seen: set[str] = set()
for item in v:
if item.sku in seen:
raise ValueError(f"duplicate sku: {item.sku}")
seen.add(item.sku)
return v
def to_domain(self) -> Order:
return Order(
customer_id=self.customer_id,
items=[
LineItem(sku=i.sku, quantity=i.quantity, unit_price=i.unit_price)
for i in self.items
],
)
class OrderResponse(BaseModel):
"""Trusted internal Order, serialised for the wire."""
id: UUID
customer_id: UUID
status: OrderStatus
items: list[LineItemPayload]
total: Decimal
@classmethod
def from_domain(cls, order: Order) -> OrderResponse:
return cls(
id=order.id,
customer_id=order.customer_id,
status=order.status,
items=[
LineItemPayload(sku=i.sku, quantity=i.quantity, unit_price=i.unit_price)
for i in order.items
],
total=order_total(order),
)
PY
log "5/6 Tests that exercise the boundary"
cat > tests/test_orders.py <<'PY'
from decimal import Decimal
from uuid import uuid4
from typing import Any
import pytest
from pydantic import ValidationError
from orders import CreateOrderRequest, OrderResponse, order_total
VALID: dict[str, Any] = {
"customer_id": str(uuid4()),
"items": [
{"sku": "SKU-1", "quantity": 2, "unit_price": "9.99"},
{"sku": "SKU-2", "quantity": 1, "unit_price": "19.50"},
],
}
def test_valid_request_parses_and_computes_total():
req = CreateOrderRequest.model_validate(VALID)
order = req.to_domain()
assert order_total(order) == Decimal("39.48")
def test_duplicate_sku_rejected():
bad = {**VALID, "items": [VALID["items"][0], VALID["items"][0]]}
with pytest.raises(ValidationError, match="duplicate sku"):
CreateOrderRequest.model_validate(bad)
def test_negative_quantity_rejected():
bad = {**VALID, "items": [{"sku": "SKU-1", "quantity": -1, "unit_price": "1.00"}]}
with pytest.raises(ValidationError):
CreateOrderRequest.model_validate(bad)
def test_response_roundtrip():
req = CreateOrderRequest.model_validate(VALID)
resp = OrderResponse.from_domain(req.to_domain())
dumped = resp.model_dump_json()
assert "\"total\":\"39.48\"" in dumped
PY
log "6/6 Install, then run mypy AND pytest"
uv venv .venv --python 3.12
# shellcheck disable=SC1091
source .venv/bin/activate
uv pip install -e . mypy pytest
echo "--- mypy ---"
mypy src/ tests/
echo "--- pytest ---"
pytest -qThis recipe targets Python 3.11+ and Pydantic v2; the example installation selects 3.12. It is an optional local setup, not a recorded installation or mypy/Pydantic runtime pass. tests/__init__.py ensures the tests.* override applies to the untyped pytest methods; VALID: dict[str, Any] deliberately models untrusted input, not trusted domain state. For a reproducible project, lock the resolved checker and library versions. Run mypy src/ tests/ and keep the real diagnostics, then run pytest separately.
What each block does
Anatomy of the codebase
Name: domain.py · @dataclass(frozen=True, slots=True) LineItem
Desc: Shallow assignment protection and a restricted instance layout. Here str, int and Decimal fields are themselves immutable/hashable; the mutable Order.items list is not protected by LineItem freezing.
Name: domain.py · OrderStatus(StrEnum)
Desc: A string subclass enum: members compare like strings, but an exact type(x) is str check can still differ. Literal[OrderStatus.PAID] denotes that enum member; a literal of its raw string is not automatically the same static type.
Name: domain.py · order_total → Decimal
Desc: Construct Decimal from decimal text for prices. Arithmetic uses context precision and rounding; define currency, cent quantisation and total-size bounds explicitly. An annotation cannot enforce those accounting rules.
Name: api.py · Field(gt=0, le=1000)
Desc: Pydantic v2's Field constraints. Runtime-checked. quantity > 0 and ≤ 1000 without writing a single validator.
Name: api.py · @field_validator('items')
Desc: An after-validator on the items field checks duplicate SKUs after its list elements validate. It is not a model-wide cross-field validator.
Name: api.py · to_domain / from_domain
Desc: Explicit conversion between the outside-world (pydantic) shape and the inside-world (dataclass) shape. Keeps concerns separated.
Name: pyproject · [tool.mypy]
Desc: strict=true enables a version-dependent bundle including typed definitions and unused-ignore checks. It does not ban all explicit Any. The override relaxes untyped test definitions only; unannotated test bodies are still checked under strict mode.
What to observe
ClassName: ml-4 list-decimal space-y-1
Run mypy and inspect the actual result; do not infer a pass from successful Python execution. Try changing quantity: int to quantity: str in LineItem: the Decimal multiplication and typed call sites should expose the mismatch.
Try quantity "2" versus "two": default Pydantic int validation can coerce the numeric string, but the word is not numeric. Use Field(strict=True, gt=0, le=1000) if the contract requires an actual integer rather than coercion; strictness is distinct from range checks.
OrderResponse.model_json_schema() describes the schema, while model_dump_json() encodes a validated response. Unknown fields are explicitly forbidden in the input models. No schema guarantees authorization or verifies that a SKU exists in a catalogue.
Benchmark validation only with real payloads and documented versions; the former sub-microsecond-per-call promise had no supporting measurement.
Goal: Feel the difference between static hints and runtime validation
Hint: Case (1) is a static mismatch; case (2) is a runtime validation error. Case (3) is also statically mistyped, but the dataclass constructor itself does not reject it at runtime. Put each case in a separate file or catch the expected exception so case (2) does not prevent case (3) from running.
Break the code three ways and see who complains:
# 1. Static: mypy catches this without running anything
from decimal import Decimal
from orders import LineItem
LineItem(sku="X", quantity="two", unit_price=Decimal("1.00")) # str where int expected
# 2. Runtime boundary: pydantic complains cleanly
from orders import CreateOrderRequest
CreateOrderRequest.model_validate({
"customer_id": "not-a-uuid",
"items": [{"sku": "X", "quantity": -1, "unit_price": "0.00"}],
}) # ValidationError with 3 sub-errors
# 3. Internal dataclass: NO runtime check — silently accepts wrong type
LineItem(sku=42, quantity=1, unit_price=1.5) # runs; sku is now an int; bug!(d) Production reality · 15 min
Gradual adoption without invented incident metrics
Start with a leaf module and its public signatures. Make the checker run non-blocking initially, collect the errors, then enforce the selected modules in CI. Narrow None before use, replace unjustified Any at internal interfaces, and use error-code-specific ignores with a reason. A successful run checks the selected files and available stubs, not an entire company. The older Dropbox story's incident percentages and eradication claims are not established here.
LLM and API payloads are data, not authority
For an incoming order, validate the whole payload before to_domain(); do not index arbitrary JSON first. A missing or misspelled customer_id, duplicate SKU or forbidden extra field produces a structured validation error. An application can return that error to a caller with a bounded retry budget, but retries need not self-correct. After schema validation, separately authorize the customer and check SKU availability. Valid JSON must not automatically trigger a purchase. No specific provider requires every tool to use Pydantic.
Decimal invariants and rounding points
Decimal("0.1") + Decimal("0.2") equals Decimal("0.3"); Decimal(0.1) instead captures the binary float's value. The sample validates positive prices to two fractional places, but taxes and discounts introduce rounding decisions. Name the rounding point (per line or invoice), currency and allowed magnitude before choosing a context.
from decimal import Decimal, ROUND_HALF_EVEN, localcontext
def rounded_line(price: str, quantity: int) -> Decimal:
if type(quantity) is not int or not 0 <= quantity <= 1000:
raise ValueError("quantity must be an integer in 0..1000")
p = Decimal(price)
if not p.is_finite() or not Decimal("0") <= p <= Decimal("99999999.99"):
raise ValueError("price outside this example's finite nonnegative range")
with localcontext() as ctx:
ctx.prec = 28
return (p * quantity).quantize(Decimal("0.01"), rounding=ROUND_HALF_EVEN)This explicit illustrative rule is not a legal/tax policy. NewType("Money", Decimal) can distinguish an API's money type statically but does not add currency or runtime validation; integer minor units are another representation.
Where this shows up in the rest of the plan
Root: Types thread through the whole learning plan
Application: FastAPI
Desc: Pydantic models ARE the request/response schema; FastAPI generates OpenAPI from them.
Application: SQLModel / SQLAlchemy 2.x
Desc: Mapped[T] annotations feed the ORM; the same class is your DB row and your type.
Application: ML feature stores
Desc: Runtime feature-schema checks can detect shape/dtype mismatches; ordinary Python type hints alone do not verify array shapes.
Application: LLM tool calling
Desc: A BaseModel can generate a tool schema; the integration must still validate and authorize each tool invocation.
Application: Structured outputs (OpenAI / Anthropic)
Desc: Parse JSON, validate it into a model and handle errors. Schema checks reduce missing-field mistakes, not all runtime failures.
Application: Config as code
Desc: pydantic-settings loads env vars into a typed Settings model; mypy checks reads.
(e) Recall + stretch · 10 min
Self-check (answer follows each question).
Recall — click each to reveal
Q: In one sentence, what does mypy do that Python does not?
A: mypy statically analyses your code against its type hints and reports mismatches without running the code. Python does not automatically validate ordinary annotations, although their evaluation/introspection and libraries using them can have runtime effects.
Q: Give a one-line example each for list[User] | None, dict[str, int], and tuple[str, int, bool].
A: list[User] | None — optional list of users. dict[str, int] — mapping from string keys to int values. tuple[str, int, bool] — a fixed-length 3-tuple.
Q: When do you reach for a dataclass vs a pydantic BaseModel?
A: Dataclass for values you own and construct internally (trusted). BaseModel for data crossing a boundary (HTTP, config, LLM output, DB seed) — anywhere the data comes from outside your process and needs validation + coercion.
Q: What does @dataclass(frozen=True, slots=True) buy you?
A: frozen=True rejects ordinary field assignment but is shallow. The generated hash works only when its participating values are hashable. slots=True restricts instance layout; memory/speed improvements depend on the class and workload.
Q: Why is Decimal the right type for money and float is wrong?
A: Decimal represents decimal inputs directly, but uses a finite precision context and rounding rules for operations. Money code must also specify currency, units, rounding points and business rules; integer minor units are another option.
Q: How do you validate that a pydantic field is a positive int ≤ 100 without writing a validator function?
A: x: int = Field(gt=0, le=100). Pydantic v2 has field-level constraints for gt/ge/lt/le, min_length/max_length, pattern, and more.
Q: You have a function that accepts either a file path (str) or an open file (IO). How do you type it?
A: def f(source: str | IO[str]) -> str: covers a path or text stream. A narrower Readable Protocol can require only read() -> str, but keep str | Readable if paths are still accepted. A protocol alone is not a path type or runtime input validator.
Q: What is TypedDict, and when would you use it over a dataclass?
A: A schema for a plain dict that mypy understands. Use it when the data must remain a dict (interop with JSON APIs, existing dict-based code) but you still want static field checking.
Q: You inherited a 5000-line untyped codebase. What's a realistic strategy to add types without stopping the world?
A: Run mypy as a non-blocking diagnostic job initially (there is no --warn-only mode), then enforce errors incrementally by module. Keep exceptions narrow and documented.
Q: Stretch — validate a webhook timestamp and required signature, then authenticate the signed bytes with a five-minute freshness rule.
A: A timestamp validator cannot authenticate a request. Here the explicit example protocol signs the ASCII integer timestamp, a dot, and the original raw body bytes with HMAC-SHA256. Both peers must implement that same protocol; a real provider's exact protocol takes precedence. Keep the shared key outside payloads/logs. Validate timestamp/signature types without coercion, reject a wrong MAC with compare_digest, then accept only ages 0 <= now - ts < 300. Freshness alone does not stop replay within the window: production also needs authenticated event IDs and atomic deduplication.
import hashlib
import hmac
import json
from typing import Any
from pydantic import BaseModel, ConfigDict, Field
class WebhookHeaders(BaseModel):
model_config = ConfigDict(extra="forbid", strict=True)
ts: int
signature: str = Field(pattern=r"^[0-9a-f]{64}$")
def verify_webhook(raw: bytes, headers: WebhookHeaders,
secret: bytes, now: int) -> dict[str, Any]:
message = str(headers.ts).encode("ascii") + b"." + raw
expected = hmac.new(secret, message, hashlib.sha256).hexdigest()
if not hmac.compare_digest(headers.signature, expected):
raise ValueError("invalid signature")
if not 0 <= now - headers.ts < 300:
raise ValueError("timestamp outside freshness window")
parsed: Any = json.loads(raw)
if not isinstance(parsed, dict):
raise ValueError("body must be a JSON object")
return parsedCall WebhookHeaders.model_validate(...) before this helper and validate the returned object against an event-specific schema before acting. Size-limit the raw body before parsing. The stdlib HMAC/freshness mechanism is separately testable; Pydantic schema checks and static typing are not implied by that test.
Explain-out-loud test
- What is the difference between a type hint and a runtime validator? (one sentence, one example)
- When would you use a dataclass and when a pydantic BaseModel? (name the boundary)
- What is one bug a configured checker can detect, and what assumption lets it escape? (concrete example)
What comes next
Next: S015 · Big-O notation.
Hub: The 6-Month Learning Plan
Part of a 130-session evergreen learning series. Session structure: intuition → visual → hands-on → production war stories → recall. Duration: 90 minutes.
Explain-out-loud answer guide
- A hint describes a type for analysis; a validator inspects an actual value.
Row(id="oops")demonstrates why the distinction matters. - Use a dataclass for constructed internal records; a BaseModel can validate an incoming request. Neither replaces authorization or business invariants.
- A checker can flag adding an int to
str | Nonewithout narrowing; Any, ignores or unchecked code can hide the mistake. It is not a universal impossibility proof.
Corrective source notes
- dataclasses — Data Classes — Python 3.14.8 documentation — Generated dataclass hashing depends on equality/frozen options; field hashability still matters. Checked 2026-10-07.
- typing — Support for type hints — Python 3.14.8 documentation — Python does not enforce ordinary function and variable annotations at runtime. Checked 2026-10-07.
- What’s new in Python 3.14 — Python 3.14.8 documentation — Python 3.14 changes ordinary annotation evaluation to deferred/lazy behavior. Checked 2026-10-07.
- 3. Data model — Python 3.14.8 documentation — Hashable keys require stable hashes and equal objects must share a hash; mutable value-equality classes should not hash changing fields. Checked 2026-10-07.
Additional primary references: Pydantic models, fields, validators, mypy common issues, mypy import discovery, Decimal context.
These citations support the named claims, not blanket certification of the lesson. Worked proofs are derived in the text; execution scope and finite-test limits are recorded separately.
Pause / Recall / Apply
Can you explain it without the page?
Close the example. Reconstruct the core idea, then change one assumption. Mark complete when you’re ready; you can always undo it.
Stored in this browser only. No account, no sync. Clearing browser data removes your record.