Python type hints (introduced in PEP 484) let you annotate variables, function parameters, and return values with expected types. They are optional at runtime but invaluable for catching bugs early with tools like mypy, pyright, and IDE auto-complete.
Quick reference
| Annotation | Example |
|---|---|
| Variable | x: int = 42 |
| Function param | def f(n: int) -> str: |
| Optional | def f(x: int | None) |
| Union | int | str |
| List | list[int] |
| Dict | dict[str, int] |
| Tuple (fixed) | tuple[int, str] |
| Tuple (variadic) | tuple[int, ...] |
| Set | set[str] |
| Callable | Callable[[int, str], bool] |
| Any | from typing import Any |
| TypeVar | T = TypeVar("T") |
| Generic class | class Stack[T]: (3.12+) |
| TypedDict | class User(TypedDict): |
| Protocol | class Drawable(Protocol): |
| Literal | Literal["GET", "POST"] |
| Final | MAX: Final = 100 |
| ClassVar | count: ClassVar[int] = 0 |
| Self | def copy(self) -> Self: |
Basic annotations
# Variables (Python 3.6+)
name: str = "Alice"
age: int = 30
score: float = 9.5
active: bool = True
data: bytes = b"\x00\xff"
# Annotation without assignment (declares type only)
user_id: int
# Built-in collection types (Python 3.9+)
names: list[str] = ["Alice", "Bob"]
mapping: dict[str, int] = {"a": 1, "b": 2}
unique: set[int] = {1, 2, 3}
pair: tuple[str, int] = ("Alice", 30)
matrix: list[list[float]] = [[1.0, 2.0], [3.0, 4.0]]
# Python 3.8 and earlier — use typing module
from typing import List, Dict, Set, Tuple
names: List[str] = ["Alice"]
mapping: Dict[str, int] = {"a": 1}
Function annotations
# Parameters and return type
def greet(name: str, times: int = 1) -> str:
return f"Hello, {name}! " * times
# No return value
def log(message: str) -> None:
print(message)
# Multiple parameters
def distance(x1: float, y1: float, x2: float, y2: float) -> float:
return ((x2 - x1) ** 2 + (y2 - y1) ** 2) ** 0.5
# *args and **kwargs
def total(*args: int) -> int:
return sum(args)
def configure(**kwargs: str) -> dict[str, str]:
return dict(kwargs)
# Callable type
from typing import Callable
transform: Callable[[int], int] = lambda x: x * x
Optional and Union
# Python 3.10+ — use | for unions
def find_user(user_id: int) -> dict[str, str] | None:
...
def process(value: int | str | float) -> str:
return str(value)
# Python 3.9 and earlier — use Optional and Union
from typing import Optional, Union
def find_user(user_id: int) -> Optional[dict[str, str]]:
# Optional[X] is shorthand for Union[X, None]
...
def process(value: Union[int, str, float]) -> str:
return str(value)
# Narrowing with isinstance
def double(value: int | str) -> int | str:
if isinstance(value, int):
return value * 2 # int here
return value + value # str here
# None checks narrow automatically
def length(s: str | None) -> int:
if s is None:
return 0
return len(s) # s is str here
TypeVar and generics
from typing import TypeVar
T = TypeVar("T")
# Generic function — T is inferred at call site
def first(items: list[T]) -> T | None:
return items[0] if items else None
result = first([1, 2, 3]) # inferred: int | None
text = first(["a", "b"]) # inferred: str | None
# Bounded TypeVar — T must be a subtype of a given type
NumberT = TypeVar("NumberT", int, float)
def maximum(a: NumberT, b: NumberT) -> NumberT:
return a if a > b else b
# Python 3.12 — new syntax (no TypeVar import needed)
def first_312[T](items: list[T]) -> T | None:
return items[0] if items else None
# Generic class (Python 3.12)
class Stack[T]:
def __init__(self) -> None:
self._items: list[T] = []
def push(self, item: T) -> None:
self._items.append(item)
def pop(self) -> T:
return self._items.pop()
def peek(self) -> T | None:
return self._items[-1] if self._items else None
# Generic class (Python 3.9-3.11)
from typing import Generic
class Repository(Generic[T]):
def __init__(self) -> None:
self._store: dict[int, T] = {}
def save(self, id: int, item: T) -> None:
self._store[id] = item
def get(self, id: int) -> T | None:
return self._store.get(id)
TypedDict
from typing import TypedDict, NotRequired
# All keys required by default
class User(TypedDict):
id: int
name: str
email: str
# Mix required and optional (Python 3.11+)
class Product(TypedDict):
id: int
name: str
price: float
description: NotRequired[str] # optional key
# Alternative: total=False makes all keys optional
class UserUpdate(TypedDict, total=False):
name: str
email: str
bio: str
def update_user(user_id: int, data: UserUpdate) -> None:
...
update_user(1, {"name": "Bob"}) # valid
update_user(1, {"name": "Bob", "bio": "Hi"}) # valid
# Nested TypedDict
class Address(TypedDict):
street: str
city: str
country: str
class UserWithAddress(TypedDict):
id: int
name: str
address: Address
Protocol (structural subtyping)
from typing import Protocol, runtime_checkable
# Define behaviour via Protocol — no inheritance needed
class Drawable(Protocol):
def draw(self) -> None: ...
def resize(self, factor: float) -> None: ...
class Circle:
def draw(self) -> None:
print("Drawing circle")
def resize(self, factor: float) -> None:
self.radius *= factor
class Square:
def draw(self) -> None:
print("Drawing square")
def resize(self, factor: float) -> None:
self.side *= factor
# Both Circle and Square satisfy Drawable without inheriting it
def render(shape: Drawable) -> None:
shape.draw()
render(Circle()) # OK
render(Square()) # OK
# runtime_checkable enables isinstance() checks
@runtime_checkable
class Sized(Protocol):
def __len__(self) -> int: ...
print(isinstance([1, 2], Sized)) # True
print(isinstance(42, Sized)) # False
Literal and Final
from typing import Literal, Final
# Literal — restrict to specific values
HttpMethod = Literal["GET", "POST", "PUT", "PATCH", "DELETE"]
StatusCode = Literal[200, 201, 400, 401, 403, 404, 500]
def make_request(method: HttpMethod, url: str) -> None:
...
make_request("GET", "/users") # OK
# make_request("CONNECT", "/users") # mypy error
# Final — constant that cannot be reassigned
MAX_RETRIES: Final = 3
API_URL: Final[str] = "https://api.example.com"
# MAX_RETRIES = 5 # mypy error: cannot assign to final name
# Final in classes
class Config:
DEBUG: Final[bool] = False
VERSION: Final = "1.0.0"
Callable types
from typing import Callable
# Callable[[arg_types], return_type]
Predicate = Callable[[int], bool]
Transformer = Callable[[str, int], str]
def filter_items(items: list[int], pred: Predicate) -> list[int]:
return [x for x in items if pred(x)]
evens = filter_items([1, 2, 3, 4], lambda x: x % 2 == 0)
# Callable with no args
Thunk = Callable[[], int]
# Generic higher-order function
T = TypeVar("T")
def apply_twice(fn: Callable[[T], T], value: T) -> T:
return fn(fn(value))
result = apply_twice(lambda x: x + 1, 5) # 7
Dataclasses with type hints
from dataclasses import dataclass, field
from typing import ClassVar
@dataclass
class Point:
x: float
y: float
def distance_to_origin(self) -> float:
return (self.x ** 2 + self.y ** 2) ** 0.5
@dataclass
class User:
id: int
name: str
email: str
tags: list[str] = field(default_factory=list)
_count: ClassVar[int] = 0 # class variable, not an instance field
def __post_init__(self) -> None:
User._count += 1
self.name = self.name.strip()
@dataclass(frozen=True) # immutable
class Color:
r: int
g: int
b: int
def to_hex(self) -> str:
return f"#{self.r:02x}{self.g:02x}{self.b:02x}"
red = Color(255, 0, 0)
# red.r = 128 # FrozenInstanceError at runtime
Running mypy
# Install
pip install mypy
# Check a file
mypy script.py
# Check a package
mypy src/
# Strict mode (recommended for new projects)
mypy --strict src/
# Common flags
mypy --ignore-missing-imports src/ # ignore missing type stubs
mypy --show-error-codes src/ # show error codes (e.g. [arg-type])
mypy --pretty src/ # formatted output
Example mypy.ini configuration:
[mypy]
python_version = 3.12
strict = True
ignore_missing_imports = True
Common mistakes
| Mistake | Problem | Fix |
|---|---|---|
Using list without subscript on Python 3.8 |
list is not generic at runtime |
Import List from typing or upgrade to 3.9+ |
Optional[X] mixed with X | None |
Inconsistent style | Pick one convention per project |
Forgetting -> None on void functions |
Implicit None — be explicit |
Always annotate return type |
Using Any everywhere |
Defeats the purpose of type hints | Use proper generics or Unknown |
| Mutable default in TypedDict values | Not a TypedDict issue but a dataclass default | Use field(default_factory=list) in dataclasses |
# type: ignore abuse |
Silences errors permanently | Fix the root cause instead |
| Not running mypy in CI | Annotations drift out of sync with code | Add mypy --strict step to CI pipeline |
FAQ
Are type hints enforced at runtime?
No. Python ignores them at runtime unless you use libraries like pydantic or beartype. They exist solely for static analysis tools (mypy, pyright) and IDE features.
What is the difference between Any and object?Any is bi-directionally compatible with every type — you can pass it anywhere and call anything on it without errors. object is the root class of all Python objects but is not assignable to specific types without narrowing. Avoid Any; use narrowing with isinstance instead.
Should I annotate every variable?
No. Annotate function signatures and class attributes. For local variables inside functions, type inference usually works correctly. Only annotate locals when inference fails or the type is ambiguous.
What is Self type (Python 3.11+)?Self refers to the current class, useful for methods that return self (builder pattern, copy methods). Before Python 3.11, you had to use TypeVar("T", bound="MyClass") as a workaround.
What is the difference between Protocol and ABC?ABC (Abstract Base Class) requires explicit inheritance. Protocol uses structural subtyping — any class with the required methods satisfies the protocol automatically, with no inheritance needed. Protocols are more flexible and Pythonic.
How do I type a dictionary with mixed value types?
Use TypedDict for a fixed set of keys with specific types. For a dynamic mapping, use dict[str, Any] or a union: dict[str, int | str | None].