Python type hints change nothing at runtime — they are a description for humans and for tooling. The benefit comes from checking inside your editor and in the pipeline: errors surface before the program runs.
Basics
# Variables and functions
name: str = "web01"
counter: int = 0
active: bool = False
values: list[int] = []
mapping: dict[str, int] = {}
def greet(name: str, loud: bool = False) -> str:
return f"Hello {name}" if not loud else f"HELLO {name.upper()}!"
def total(numbers: list[float]) -> float:
return sum(numbers)
def find_server(hostname: str) -> dict[str, str] | None:
"Returns None if the server does not exist."
...
from typing import Optional, Union, Any
# Both forms are equivalent (the pipe form is preferred from 3.10):
def a(x: Optional[str]) -> None: ... # older
def b(x: str | None) -> None: ... # 3.10+, clearer
# Since 3.9 the builtin generics work directly:
# list[int] instead of List[int]
# dict[str, int] instead of Dict[str, int]
# tuple[int, str] instead of Tuple[int, str]
# set[str] instead of Set[str]
The tools that do the checking
# 1. Static checking - without running the program
pip install mypy
mypy app.py
mypy --strict app.py
mypy app.py --ignore-missing-imports
# 2. Faster checker, great for CI
pip install pyright
pyright app.py
# 3. Editor integration - where the daily benefit comes from
# VS Code: Pylance (bundles pyright)
# Neovim: pyright or mypy via LSP
# -> errors appear WHILE you type, not in production
def process(numbers: list[int]) -> int:
return sum(numbers)
process([1, 2, 3]) # ok
process(["a", "b"]) # error: list[str] is not list[int]
process("123") # error: str is not list[int]
# All three variants run in plain Python until the crash
# - or worse, until they silently return a wrong result.
More advanced types
from collections.abc import Iterable, Callable, Iterator, Sequence
from typing import Protocol, TypedDict, Literal, TypeVar
# Input: "I accept anything iterable" - not just a list
def mean(values: Iterable[float]) -> float:
values = list(values)
return sum(values) / len(values)
mean([1.0, 2.0])
mean({1.0, 2.0}) # a set works too
mean(x for x in range(5)) # generator - no memory cost
# Output: "I return an iterator" - important for large data
def read_lines(path: str) -> Iterator[str]:
with open(path, encoding="utf-8") as f:
for line in f:
yield line.rstrip("\n")
def apply_all(values: list[int], f: Callable[[int], int]) -> list[int]:
return [f(v) for v in values]
# Structured dictionaries instead of dict[str, object]
class Server(TypedDict):
name: str
ip: str
port: int
def connect(server: Server) -> None:
print(server["name"], server["ip"], server["port"])
# Typos in the keys "name"/"ip"/"port" are reported!
Mode = Literal["read", "write", "append"]
def open_file(path: str, mode: Mode = "read") -> None: ...
open_file("x.txt", "write") # ok
open_file("x.txt", "write!") # error - not in the Literal
# Describe behaviour instead of inheriting from a base class
class Closeable(Protocol):
def close(self) -> None: ...
def cleanup(objects: list[Closeable]) -> None:
for o in objects:
o.close()
# Any class with a matching close() method fits - without
# inheriting from Closeable. Very practical in real code.
T = TypeVar("T")
def first(values: Sequence[T]) -> T | None:
return values[0] if values else None
# mypy infers: first([1,2]) -> int | None
# first(["a"]) -> str | None
What type hints do not do
- No runtime checking. Python does not enforce annotations.
process("123")runs fine unless a static checker is involved. - No type conversion.
values: list[int] = input()— the annotation converts nothing. A frequent beginner misconception. - No guarantee for external data. A JSON response is unknown at runtime. That needs real validation.
- No performance gain. Annotations do not make programs faster.
Runtime validation where it matters
# Pydantic is the standard for data from outside (APIs, files, forms)
from pydantic import BaseModel, Field, field_validator
class Server(BaseModel):
name: str = Field(min_length=1, max_length=64)
ip: str
port: int = Field(ge=1, le=65535)
@field_validator("ip")
@classmethod
def ip_must_be_valid(cls, v: str) -> str:
import ipaddress
try:
ipaddress.ip_address(v) # real validation now!
except ValueError as exc:
raise ValueError(f"invalid IP: {v}") from exc
return v
raw = '{"name": "web01", "ip": "10.0.0.11", "port": 8080}'
server = Server.model_validate_json(raw)
print(server.name, server.ip, server.port)
# For data from your own program, mypy is enough.
# For data from outside, real validation is mandatory.
Adopting them in an existing project
- Not everything at once. Running
mypyon a grown project produces hundreds of findings. Adopt incrementally. - Exclude what does not fit yet:
# mypy.ini [mypy] python_version = 3.11 warn_unused_ignores = True [mypy-legacy_module.*] ignore_errors = True # New modules checked strictly right away, old ones later. - Handle suppressions with a reason.
# type: ignorewithout a comment hides a bug. Withwarn_unused_ignores = True, mypy tells you when a suppression is no longer needed. - Put it in the pipeline. Without enforcement, annotations rot and become misleading.
- Do not flee into
Any. If everything isAny, the benefit is gone.objector aProtocolis usually the better answer.
Related: Python for beginners, Python functions and modules, Python, Python-Typannotationen (DE).