Python type hints (introduced in PEP 484, Python 3.5+) allow you to annotate your code with type information. They do not affect runtime behavior — Python remains dynamically typed — but enable static analysis tools like mypy, pyright, and IDE autocompletion to catch bugs before you run your code. In 2026, type hints are standard practice for any serious Python project.
Basic Type Annotations
Type annotations use a colon after variable or parameter names, and -> for return types. Python 3.9+ allows using built-in generics directly (list[str] instead of List[str]).
# Python type hints — basic built-in types
def greet(name: str) -> str:
return f"Hello, {name}!"
def add(x: int, y: int) -> int:
return x + y
def ratio(total: int, count: int) -> float:
return total / count
def is_valid(value: str) -> bool:
return len(value) > 0
# Variables can be annotated too
user_id: int = 42
username: str = "alice"
scores: list[int] = [95, 87, 92]Collections and Optional Types
The typing module provides generic versions of collections and special forms for nullable and union types.
from typing import Optional, Union, Any
# Python 3.9+: use built-in generics directly
names: list[str] = ["Alice", "Bob"]
counts: dict[str, int] = {"apples": 3, "bananas": 5}
coords: tuple[float, float] = (51.5, -0.1)
unique_ids: set[int] = {1, 2, 3}
# Optional: value may be None
def find_user(user_id: int) -> Optional[str]:
db = {1: "Alice", 2: "Bob"}
return db.get(user_id) # may return None
# Union: multiple possible types (Python 3.10+: use X | Y)
def process(value: Union[int, str]) -> str:
return str(value)
# Python 3.10+ syntax
def process_new(value: int | str) -> str:
return str(value)
# Any: opt out of type checking
def legacy(data: Any) -> Any:
return dataAdvanced typing Module Features
The typing module offers powerful constructs for describing complex type relationships: Callable, TypeVar, Generic classes, Literal, Final, and TypedDict.
from typing import (
Callable, Iterator, Generator,
TypeVar, Generic, Protocol,
TypedDict, Literal, Final,
overload, cast, TYPE_CHECKING
)
# Callable: function signatures
Handler = Callable[[str, int], bool]
def apply(func: Callable[[int], int], value: int) -> int:
return func(value)
# TypeVar: generic type parameters
T = TypeVar('T')
K = TypeVar('K')
V = TypeVar('V')
def first(items: list[T]) -> T:
return items[0]
# Generic class
class Stack(Generic[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 is_empty(self) -> bool:
return len(self._items) == 0
# Usage
stack: Stack[int] = Stack()
stack.push(1)
stack.push(2)
result: int = stack.pop() # mypy knows this is int
# Literal: restrict to specific values
def set_direction(direction: Literal["left", "right", "up", "down"]) -> None:
print(f"Moving {direction}")
# Final: constant that cannot be reassigned
MAX_SIZE: Final = 100
# TypedDict: typed dictionaries
class UserDict(TypedDict):
name: str
age: int
email: str
def process_user(user: UserDict) -> str:
return f"{user['name']} ({user['age']})"Protocol: Structural Subtyping
Protocol enables duck typing with type safety. Instead of requiring inheritance, Protocol checks that an object has the required methods and attributes.
from typing import Protocol, runtime_checkable
# Protocol: structural subtyping (duck typing with types)
@runtime_checkable
class Drawable(Protocol):
def draw(self) -> None: ...
def get_color(self) -> str: ...
class Circle:
def draw(self) -> None:
print("Drawing circle")
def get_color(self) -> str:
return "red"
class Square:
def draw(self) -> None:
print("Drawing square")
def get_color(self) -> str:
return "blue"
def render(shape: Drawable) -> None:
print(f"Color: {shape.get_color()}")
shape.draw()
# Both work without inheriting from Drawable
render(Circle())
render(Square())
# Runtime check (requires @runtime_checkable)
print(isinstance(Circle(), Drawable)) # Truedataclasses with Type Hints
dataclasses combine perfectly with type hints, providing auto-generated __init__, __repr__, and __eq__ based on annotated fields.
from dataclasses import dataclass, field
from typing import ClassVar
@dataclass
class Point:
x: float
y: float
def distance_to(self, other: 'Point') -> float:
return ((self.x - other.x) ** 2 + (self.y - other.y) ** 2) ** 0.5
@dataclass
class Employee:
name: str
department: str
salary: float
skills: list[str] = field(default_factory=list)
_id_counter: ClassVar[int] = 0
def __post_init__(self) -> None:
Employee._id_counter += 1
self.employee_id: int = Employee._id_counter
def add_skill(self, skill: str) -> None:
self.skills.append(skill)
# Frozen dataclass (immutable)
@dataclass(frozen=True)
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)
print(red.to_hex()) # #ff0000Running mypy and Configuration
mypy is the most popular static type checker for Python. Install it with pip install mypy, then run it on your code.
# Install and run mypy
pip install mypy
mypy src/
# With strict mode (catches more issues)
mypy --strict src/
# Check a single file
mypy mymodule.pymypy.ini Configuration
# mypy.ini
[mypy]
python_version = 3.12
warn_return_any = True
warn_unused_configs = True
disallow_untyped_defs = True
disallow_any_generics = True
check_untyped_defs = True
strict_optional = True
no_implicit_optional = True
# Ignore missing stubs for third-party packages
[mypy-requests.*]
ignore_missing_imports = True
[mypy-numpy.*]
ignore_missing_imports = TrueType Hint Syntax: Python Version Comparison
| Feature | Python 3.8 | Python 3.9 | Python 3.10 | Python 3.12 |
|---|---|---|---|---|
| Basic annotations | typing.List, Dict | list, dict | list, dict | list, dict |
| Union types | Union[X, Y] | Union[X, Y] | X | Y | X | Y |
| Optional | Optional[X] | Optional[X] | X | None | X | None |
| TypeAlias | MyType = ... | MyType = ... | TypeAlias | type MyType = ... |
| ParamSpec | N/A | N/A | ParamSpec | ParamSpec |
| Self type | N/A | N/A | N/A | Self |
Best Practices
- Start with return types and public API parameters — they give the most value for the least effort.
- Use Optional[X] or X | None (Python 3.10+) whenever a value can be None. Never use Any unless interfacing with truly dynamic code.
- Run mypy in CI with --strict or at least --disallow-untyped-defs to prevent type annotation regression.
- Use Protocol over ABC for structural typing — it works without the class hierarchy.
- Use TypedDict for dictionary structures, dataclasses for data objects, NamedTuple for lightweight immutable records.
Frequently Asked Questions
Do Python type hints slow down my code?
No. Type hints are completely ignored at runtime (unless you use typing.get_type_hints() explicitly). They add zero runtime overhead. The Python interpreter processes them as expressions that evaluate to nothing relevant.
What is the difference between mypy and pyright?
mypy is the original Python type checker, developed by Guido van Rossum's team. pyright is Microsoft's type checker, used in VS Code (Pylance). pyright is generally faster and stricter. Both support the same PEP standards. For CI, mypy is more configurable. For IDE integration, pyright (via Pylance) is excellent.
When should I use TypeVar vs Protocol?
Use TypeVar when you need generic functions or classes where the type is preserved (e.g., a function that returns the same type it receives). Use Protocol when you want to type-check that an object has certain methods/attributes, regardless of its class hierarchy.
How do I type hint *args and **kwargs?
Use *args: int for positional variadic arguments (all must be the same type), and **kwargs: str for keyword variadic arguments. For mixed types, use *args: Any. Python 3.11+ introduces TypeVarTuple for variadic generics.
Should I add type hints to existing Python code?
Yes, incrementally. Start with public functions and class methods. Use "# type: ignore" sparingly to suppress errors in complex third-party integrations. Enable mypy on new files first, then gradually expand coverage. The benefit compounds over time as the codebase grows.