What you'll learn
- Annotate parameters, return values, and variables with types
- Write the everyday hints: built-in types,
list[str],dict[str, int],X | None,Callable,Any - Understand that hints are for readers and tools, and that Python does not enforce them at runtime
- Know what changed in 3.14: annotations are evaluated lazily
Concept
A type hint is a note attached to a name saying what kind of value it is meant to hold: def total(prices: list[float]) -> float:. Python reads the note, stores it, and otherwise ignores it. Passing a list of strings will not raise an error at the call. The hints exist for two audiences: people reading the code, who now see the shape of every function without reading its body, and tools, which check that the shapes agree across a whole codebase and flag the mismatches before anything runs. The next lesson is about the tool; this one is about writing the hints.

The everyday vocabulary is small. Built-in types name themselves: int, str, float, bool, bytes. Containers take their item types in brackets: list[str], dict[str, int], tuple[int, int], set[str]. A value that may be missing is str | None, read "string or None", and it is the single most useful hint because it tells callers they must handle the None. X | Y unions cover a value that can be one of several types. Your own classes, dataclasses, and enums are types too, used by name.
| Hint | Means |
|---|---|
int, str, float, bool |
exactly that built-in type |
list[str] |
a list whose items are strings |
dict[str, int] |
string keys, integer values |
tuple[int, str] |
a pair: an int then a str |
tuple[int, ...] |
any number of ints |
str | None |
a string, or None |
int | str |
either type |
Callable[[int, str], bool] |
a function taking (int, str), returning bool |
Iterable[str], Sequence[int] |
anything loopable / indexable of that type |
Any |
opt out; anything goes |
None as a return |
the function returns nothing |
Prefer the abstract collection types from collections.abc for parameters: a function that only loops over its argument should say Iterable[str], not list[str], so callers can pass a tuple, a set, or a generator. Use the concrete type for return values, since you know exactly what you are handing back. Callable describes functions passed as arguments, and Any is the escape hatch for values you genuinely cannot describe; use it rarely and on purpose. Type aliases give a long hint a name: type UserId = int (3.12 syntax) or Matrix = list[list[float]].
Since Python 3.14, annotations are evaluated lazily: the expressions after the colons are not run when the function is defined, only when something asks for them through annotationlib or typing.get_type_hints. That means a method can mention its own class, or a class defined later in the file, without quotes or a from __future__ import annotations line. It also means a typo in an annotation does not crash at import time, which is exactly why hints are for tools: the checker will still catch it.

Code
A function with full hints, called correctly and incorrectly. The incorrect call runs anyway, which is the point: hints do not enforce, they inform.
# hints_basic.py
def total(prices: list[float], tax_rate: float = 0.2) -> float:
"""Sum the prices and add tax."""
return round(sum(prices) * (1 + tax_rate), 2)
def greeting(name: str | None) -> str:
return "hello, stranger" if name is None else f"hello, {name}"
print(total([10.0, 5.5]))
print(greeting("ada"), "|", greeting(None))
print(total.__annotations__)
print(total(["10", "5"]) if False else "a wrong call would still run; mypy flags it")
18.6
hello, ada | hello, stranger
{'prices': list[float], 'tax_rate': <class 'float'>, 'return': <class 'float'>}
a wrong call would still run; mypy flags itContainers, optionals, callables, and abstract types from collections.abc, on a small set of functions. The variable annotation documents a container before it is filled.
# hints_collections.py
from collections.abc import Callable, Iterable, Sequence
def word_lengths(words: Iterable[str]) -> dict[str, int]:
return {w: len(w) for w in words}
def first_and_last(items: Sequence[int]) -> tuple[int, int]:
return items[0], items[-1]
def apply_all(values: list[float], fn: Callable[[float], float]) -> list[float]:
return [fn(v) for v in values]
scores: dict[str, list[int]] = {}
scores.setdefault("ada", []).append(91)
print(word_lengths(("ada", "grace")))
print(word_lengths({"linus"}))
print(first_and_last([4, 9, 2]))
print(apply_all([1.0, 2.5], lambda v: v * 2))
print(scores)
{'ada': 3, 'grace': 5}
{'linus': 5}
(4, 2)
[2.0, 5.0]
{'ada': [91]}Your own types in hints, plus aliases with the 3.12 type statement. A dataclass and an enum are used as types like any built-in.
# own_types.py
from dataclasses import dataclass
from enum import Enum
type UserId = int
type Matrix = list[list[float]]
class Role(Enum):
ADMIN = "admin"
USER = "user"
@dataclass
class User:
id: UserId
name: str
role: Role = Role.USER
def find(users: list[User], user_id: UserId) -> User | None:
return next((u for u in users if u.id == user_id), None)
def scale(m: Matrix, factor: float) -> Matrix:
return [[v * factor for v in row] for row in m]
users = [User(1, "ada", Role.ADMIN), User(2, "linus")]
print(find(users, 2), find(users, 9))
print(scale([[1.0, 2.0], [3.0, 4.0]], 0.5))
print(UserId, UserId.__value__)
User(id=2, name='linus', role=<Role.USER: 'user'>) None
[[0.5, 1.0], [1.5, 2.0]]
UserId <class 'int'>Lazy annotations in 3.14. A method refers to its own class with no quotes, a typo in an annotation does not break the import, and get_type_hints evaluates them on demand.
# lazy_annotations.py
import typing
class Node:
def __init__(self, value: int, next_node: Node | None = None):
self.value = value
self.next_node = next_node
def link(self, other: Node) -> Node:
self.next_node = other
return other
def broken_hint(x: NotARealType) -> int:
return x
chain = Node(1).link(Node(2))
print(chain.value)
print(typing.get_type_hints(Node.link))
print("import succeeded despite the bad hint; mypy would report it")
2
{'other': <class '__main__.Node'>, 'return': <class '__main__.Node'>}
import succeeded despite the bad hint; mypy would report itTry it yourself
Create typed_inventory.py. Write a Product dataclass with typed fields, a type Inventory = dict[str, Product] alias, and functions add(inv: Inventory, product: Product) -> None, find(inv: Inventory, name: str) -> Product | None, total_value(inv: Inventory) -> float, and apply_discount(inv: Inventory, rule: Callable[[Product], float]) -> dict[str, float]. Hint every parameter and return, run it to confirm it works, and keep it for the next lesson, where mypy will check it.
Common mistakes
- Expecting hints to enforce.
total(["a"])runs and fails somewhere inside; only a checker catches it up front. - Over-specific parameters.
list[str]refuses a tuple in the checker's eyes;Iterable[str]orSequence[str]accepts what you actually need. Anyas a default. It switches checking off for that value; write the real type, or a union, and reserveAnyfor the genuinely dynamic.
What's next
- Hints annotate parameters, returns, and variables for readers and tools; Python stores them and runs as usual.
list[str],dict[str, int],X | None,Callable, and thecollections.abctypes cover nearly everything; aliases name the rest.- In 3.14 annotations are lazy, so forward references need no quotes and hint typos surface in the checker, not at import.
The hints are written. Next you will run the tool that reads them, mypy, and learn the advanced forms it understands: Protocols, TypedDicts, and generics.