Home› Tutorials› Modern Python
Modern PythonType Hints in Python: Annotate Code Without Changing Its Behaviour
- Type hints are optional annotations; Python ignores them at runtime.
- Static analysis tools like
mypyand IDE checkers use them to catch errors before execution. - Use
X | None(Python 3.10+) orOptional[X]for values that may be absent. - Built-in types like
list,dict, andtupleare directly usable as generic aliases since Python 3.9.
Basic variable annotations
A variable annotation uses a colon after the name. The annotation is an expression, typically a type, but Python does not enforce it at runtime:
count: int = 0
name: str = "unnamed"
ratio: float
active: bool = True
An annotated variable without a value (ratio: float) declares a name and its expected type but does not create the variable in the usual sense—accessing it before assignment still raises NameError.
Annotating function signatures
Parameters get a colon annotation; the return type uses an arrow ->. Both are part of the function's __annotations__ dict but have no runtime effect on how the function behaves:
def greet(name: str, repeat: int = 1) -> str:
return (f"Hello, {name}! " * repeat).strip()
def save_user(user_id: int, data: dict) -> None:
... # None means no return value
def transform(values: list[float]) -> list[float]:
return [v * 1.1 for v in values]
The benefit shows up in your editor and in mypy: calling greet(42) will trigger a type error because 42 is int, not str, even though Python would happily run it.
Optional values and None
When a value might be None, express it explicitly. Python 3.10 introduced the union operator | for this:
# Python 3.10+ syntax
def find_user(user_id: int) -> dict | None:
...
# Equivalent older syntax using Optional from typing
from typing import Optional
def find_user(user_id: int) -> Optional[dict]:
...
Declaring the possibility of None in the return type forces callers to handle it. A type checker will flag code that accesses the returned dict without first checking for None.
Union types
Use | when a parameter or return value can be one of several types:
def process(value: int | float | str) -> str:
return str(value).strip()
# Older typing module equivalent:
from typing import Union
def process(value: Union[int, float, str]) -> str:
...
Unions are most useful in interfaces that accept multiple input forms, for example a function that takes either a file path as a string or a Path object.
Generics: typed containers
Since Python 3.9, built-in collection types accept type parameters directly:
def top_n(scores: dict[str, int], n: int) -> list[str]:
return sorted(scores, key=scores.get, reverse=True)[:n]
Matrix = list[list[float]]
def flatten(matrix: Matrix) -> list[float]:
return [cell for row in matrix for cell in row]
For older code bases (Python 3.8), import the capitalised versions from typing: List, Dict, Tuple, Set.
Annotated vs unannotated: a comparison
| Without hints | With hints | Benefit |
|---|---|---|
def calc(x, y): |
def calc(x: float, y: float) -> float: |
IDE shows parameter types on hover |
result = find(id) |
result: User | None = find(id) |
Checker warns if None not handled |
items = [] |
items: list[str] = [] |
Appending non-string triggers warning |
config = {} |
config: dict[str, Any] = {} |
Documents expected shape of the dict |
Type hints at runtime
Python stores annotations in __annotations__ but does not evaluate or enforce them during execution. You can read them programmatically:
def greet(name: str) -> str:
return f"Hello, {name}"
print(greet.__annotations__)
# {'name': , 'return': }
Libraries like dataclasses and pydantic do read annotations at runtime to generate behaviour—but that is a feature of those libraries, not of Python's type system itself. A plain function's annotations are purely informational unless something explicitly reads __annotations__.
Start annotating function signatures in new code. You do not need to annotate every local variable; focus on the boundaries where types matter most: public API parameters, return values, and data structure shapes.