Python is dynamically typed — you never have to declare types. But since Python 3.5, you can add optional type hints that make your code self-documenting, catch bugs before they happen, and give your IDE superpowers. In 2026, type hints are standard practice in every production Python codebase.
1. Why Use Type Hints?
- Better IDE support: Autocomplete, refactoring, and error detection improve dramatically with type hints
- Catch bugs before runtime: Tools like
mypyfind type errors without running your code - Self-documenting code: Types tell you what a function expects and returns — no guessing
- Team collaboration: New team members understand code faster
Type hints are optional. They never affect runtime behavior. Python ignores them completely. They are purely for development tooling and documentation.
2. Basic Type Hints
Primitive Types
name: str = "Alice"
age: int = 30
price: float = 19.99
is_active: bool = True
Function Signatures
def greet(name: str) -> str:
return f"Hello, {name}!"
def add(a: int, b: int) -> int:
return a + b
def calculate_discount(price: float, discount_pct: float) -> float:
return price * (1 - discount_pct / 100)
# Function that returns nothing
def log_message(msg: str) -> None:
print(f"[LOG] {msg}")
Collections
from typing import List, Dict, Set, Tuple
names: List[str] = ["Alice", "Bob", "Charlie"]
scores: Dict[str, int] = {"Alice": 95, "Bob": 87}
unique_ids: Set[int] = {1, 2, 3}
coordinate: Tuple[float, float] = (40.7, -74.0)
3. Optional and Union Types
Optional (can be None)
from typing import Optional
def find_user(user_id: int) -> Optional[str]:
users = {1: "Alice", 2: "Bob"}
return users.get(user_id) # Returns str or None
result = find_user(3) # result is Optional[str], could be None
if result is not None:
print(result.upper()) # mypy knows result is str here
Union (multiple possible types)
from typing import Union
def process_input(data: Union[str, int, List[str]]) -> str:
if isinstance(data, int):
return str(data * 2)
elif isinstance(data, list):
return ", ".join(data)
return data
In Python 3.10+, use the cleaner | syntax:
def process_input(data: str | int | list[str]) -> str:
if isinstance(data, int):
return str(data * 2)
elif isinstance(data, list):
return ", ".join(data)
return data
4. Complex Type Hints
TypedDict (typed dictionaries)
from typing import TypedDict
class User(TypedDict):
name: str
age: int
email: str
def create_user(data: User) -> str:
return f"Created user {data['name']}, age {data['age']}"
alice: User = {"name": "Alice", "age": 30, "email": "alice@example.com"}
print(create_user(alice))
Callable (functions as arguments)
from typing import Callable
def apply(func: Callable[[int, int], int], x: int, y: int) -> int:
return func(x, y)
def multiply(a: int, b: int) -> int:
return a * b
result = apply(multiply, 3, 4) # 12
5. Static Checking with mypy
mypy --strict on new projects. For existing codebases, begin with no flags and add --disallow-untyped-defs to catch new untyped functions. Adding all strict checks at once on legacy code produces hundreds of errors — not productive.Type hints alone do nothing. mypy is the tool that checks them:
# Install
pip install mypy
# Check your code
mypy my_script.py
Example: catching a bug before running
# buggy.py
def double(n: int) -> int:
return n * 2
print(double("hello")) # mypy catches this BEFORE runtime
$ mypy buggy.py
buggy.py:4: error: Argument 1 to "double" has incompatible type "str"; expected "int"
6. Best Practices in 2026
- Start with public APIs: Add type hints to function signatures first. Internal variables can wait.
- Use modern syntax:
list[str]notList[str],str | NonenotOptional[str](Python 3.10+) - Add mypy to CI: Run
mypyon every pull request. Do not let untyped code accumulate. - Incrementally adopt: Add
--strictgradually. Start with--disallow-untyped-defsfor new functions. - Use type stubs for libraries: Many popular libraries ship type stubs (
types-requests,types-pytz). Install them.
Type hints are a gradient, not a binary. You do not need 100% coverage to get value. Even 20% coverage on public APIs makes a massive difference.
7. Common Mistakes
7.1. Using List Instead of list in Python 3.9+
In Python 3.9+, you can write list[str] directly without importing from typing. The capitalized versions (List, Dict, Tuple) are deprecated and will be removed in a future Python version. Use lowercase built-in generics instead.
# Deprecated (Python 3.5-3.8 style, still works but not recommended)
from typing import List
names: List[str] = []
# Modern (Python 3.9+)
names: list[str] = []
7.2. Forgetting That Type Hints Are Optional at Runtime
Type hints do not enforce anything at runtime. This code runs without errors even though it violates the type annotation:
def add(a: int, b: int) -> int:
return a + b
add("hello", "world") # No error at runtime, mypy catches this
Always run mypy as part of your workflow. Type hints without a checker are just comments.
7.3. Over-Annotating Internal Variables
Annotating every local variable creates visual noise. Focus type annotations on function signatures, class attributes, and public APIs. The type checker infers types for local variables from their initial values.
# Over-annotated — mypy already knows x is int
def calculate() -> int:
x: int = 5
y: int = x * 2
return y
# Clean — annotate the boundary, let inference handle the rest
def calculate() -> int:
x = 5
y = x * 2
return y
FAQ
Do type hints make Python slower?
No. Type hints are ignored at runtime. They have zero performance impact.
Should I add type hints to existing large codebases?
Add them gradually. Start with new functions and public APIs. Use mypy in loose mode initially, tighten over time.
What is the difference between typing and type annotations?
Type annotations are the syntax (name: str). The typing module provides advanced type constructs (Optional, Union, TypedDict).