Python Type Hints: The Complete Guide for 2026
Python is dynamically typed, but since PEP 484 landed in Python 3.5, type hints have transformed how teams build reliable Python software. Type annotations catch bugs before runtime, power IDE autocompletion, serve as living documentation, and enable tools like mypy and Pydantic. This guide covers everything from basic annotations through advanced generics, Protocol, TypeGuard, ParamSpec, and the new Python 3.12 type parameter syntax.
1. Why Type Hints? The PEPs That Built the System
Type hints are optional annotations that declare what types a function expects and returns. Python does not enforce them at runtime — they exist for static analysis tools, IDEs, and documentation. The system evolved through several PEPs:
- PEP 484 (Python 3.5) — introduced type hints and the
typingmodule - PEP 526 (Python 3.6) — variable annotations:
name: str = "Alice" - PEP 585 (Python 3.9) — built-in generics:
list[int]instead ofList[int] - PEP 604 (Python 3.10) — union syntax:
int | strinstead ofUnion[int, str] - PEP 612 (Python 3.10) — ParamSpec for decorator typing
- PEP 673 (Python 3.11) — Self type for methods returning their own class
- PEP 695 (Python 3.12) — type parameter syntax:
def f[T](x: T) -> T
# Without type hints -- what does this return?
def process(data, config):
...
# With type hints -- instantly clear
def process(data: list[dict[str, str]], config: AppConfig) -> ProcessResult:
...
2. Basic Type Annotations
The simplest annotations use Python's built-in types directly:
# Function parameters and return types
def greet(name: str) -> str:
return f"Hello, {name}!"
def calculate_tax(amount: float, rate: float = 0.2) -> float:
return amount * rate
def is_valid(token: str) -> bool:
return len(token) == 32
# Functions that return nothing
def log_message(msg: str) -> None:
print(f"[LOG] {msg}")
# Variable annotations (PEP 526)
name: str = "Alice"
age: int = 30
score: float = 95.5
active: bool = True
data: bytes = b"\x00\x01"
3. Collection Types (Python 3.9+ Syntax)
Since Python 3.9, you can use built-in collection types directly as generics. No imports from typing needed:
# Lists -- homogeneous sequences
names: list[str] = ["Alice", "Bob", "Charlie"]
matrix: list[list[int]] = [[1, 2], [3, 4]]
# Dictionaries -- key-value mappings
scores: dict[str, int] = {"Alice": 95, "Bob": 87}
config: dict[str, str | int | bool] = {"host": "localhost", "port": 8080, "debug": True}
# Sets -- unique unordered collections
tags: set[str] = {"python", "typing", "mypy"}
# Tuples -- fixed length with specific types per position
point: tuple[float, float] = (3.14, 2.71)
rgb: tuple[int, int, int] = (255, 128, 0)
# Variable-length tuples (homogeneous)
values: tuple[int, ...] = (1, 2, 3, 4, 5)
# Frozensets
immutable_tags: frozenset[str] = frozenset({"a", "b"})
# For Python 3.7-3.8 compatibility, add this import:
from __future__ import annotations # Makes all annotations strings
4. Optional and Union Types
When a value can be one of several types, use Union. Python 3.10 introduced the | syntax (PEP 604):
# Python 3.10+ -- pipe syntax
def find_user(user_id: int) -> User | None:
"""Returns a User or None if not found."""
return db.get(user_id)
def parse_input(value: str | int | float) -> float:
return float(value)
# Python 3.9 and earlier -- use typing imports
from typing import Optional, Union
def find_user(user_id: int) -> Optional[User]: # Same as Union[User, None]
return db.get(user_id)
def parse_input(value: Union[str, int, float]) -> float:
return float(value)
# IMPORTANT: Optional[X] does NOT mean the parameter is optional.
# It means the TYPE can be X or None.
# A parameter is optional when it has a default value:
def search(query: str, limit: int | None = None) -> list[Result]:
...
5. TypeVar and Generic Classes
TypeVar creates type variables that represent consistent-but-unknown types within a scope. Generic makes classes parameterizable:
from typing import TypeVar, Generic
T = TypeVar("T")
# Generic function -- T is consistent within one call
def first(items: list[T]) -> T:
return items[0]
name = first(["Alice", "Bob"]) # mypy infers str
num = first([1, 2, 3]) # mypy infers int
# Bounded TypeVar -- restricts to specific types or subclasses
Numeric = TypeVar("Numeric", int, float)
def add(a: Numeric, b: Numeric) -> Numeric:
return a + b
# Upper-bound TypeVar
from typing import SupportsFloat
N = TypeVar("N", bound=SupportsFloat)
# 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 peek(self) -> T:
return self._items[-1]
int_stack: Stack[int] = Stack()
int_stack.push(42) # OK
int_stack.push("hello") # mypy error: str is not int
# Python 3.12+ -- new type parameter syntax (PEP 695)
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()
6. Protocol: Structural Subtyping (Duck Typing for Type Checkers)
Protocol defines interfaces based on structure, not inheritance. Any class with the required methods satisfies the protocol automatically:
from typing import Protocol, runtime_checkable
class Readable(Protocol):
def read(self, n: int = -1) -> str: ...
class Closeable(Protocol):
def close(self) -> None: ...
class ReadableCloseable(Readable, Closeable, Protocol):
"""Combines multiple protocols."""
...
# This works -- StringIO has read() and close()
import io
def process_stream(source: ReadableCloseable) -> str:
data = source.read()
source.close()
return data
result = process_stream(io.StringIO("hello")) # OK, structurally matches
# runtime_checkable enables isinstance() checks
@runtime_checkable
class Sized(Protocol):
def __len__(self) -> int: ...
print(isinstance([1, 2, 3], Sized)) # True
print(isinstance(42, Sized)) # False
# Protocol vs ABC:
# - ABC: class MUST inherit from it (nominal typing)
# - Protocol: class just needs matching methods (structural typing)
# Use Protocol when you want to accept third-party objects without
# forcing them to change their inheritance hierarchy.
7. TypedDict and NamedTuple
TypedDict types dictionaries with specific string keys and per-key value types. NamedTuple creates immutable typed tuples:
from typing import TypedDict, NamedTuple
# TypedDict -- typed dictionary with known string keys
class UserDict(TypedDict):
name: str
email: str
age: int
class PartialUser(TypedDict, total=False):
"""All keys are optional."""
name: str
email: str
class UserUpdate(TypedDict, total=False):
name: str
email: str
age: int
def create_user(data: UserDict) -> int:
# data["name"] is str, data["age"] is int
return save_to_db(data)
user: UserDict = {"name": "Alice", "email": "alice@dev.io", "age": 30}
# Required + optional keys combined (Python 3.11+)
class APIResponse(TypedDict):
status: int
data: list[dict[str, str]]
class APIResponseFull(APIResponse, total=False):
error: str # optional key
pagination: dict # optional key
# NamedTuple -- immutable records with types
class Point(NamedTuple):
x: float
y: float
label: str = "origin" # default value
p = Point(3.0, 4.0)
print(p.x, p.y) # attribute access
print(p[0], p[1]) # index access
x, y, label = p # unpacking
8. Callable Types and Function Signatures
Use Callable to type functions passed as arguments, callbacks, and higher-order functions:
from collections.abc import Callable
# Basic callable: takes (int, int), returns int
def apply(func: Callable[[int, int], int], a: int, b: int) -> int:
return func(a, b)
result = apply(lambda x, y: x + y, 3, 5) # 8
# Callable with no arguments
def run_task(task: Callable[[], None]) -> None:
task()
# Callback pattern
def fetch_data(url: str, on_success: Callable[[dict], None],
on_error: Callable[[Exception], None]) -> None:
try:
data = requests.get(url).json()
on_success(data)
except Exception as e:
on_error(e)
# For complex signatures, use Protocol instead of Callable:
class Comparator(Protocol):
def __call__(self, a: str, b: str, *, reverse: bool = False) -> int: ...
def sort_items(items: list[str], cmp: Comparator) -> list[str]:
...
9. Literal, Final, and ClassVar
These types constrain values beyond basic types:
from typing import Literal, Final, ClassVar
# Literal -- restrict to specific values
def set_mode(mode: Literal["read", "write", "append"]) -> None:
...
set_mode("read") # OK
set_mode("execute") # mypy error: not a valid literal
# Useful for status codes, directions, modes
Direction = Literal["north", "south", "east", "west"]
HttpMethod = Literal["GET", "POST", "PUT", "DELETE", "PATCH"]
def request(method: HttpMethod, url: str) -> Response:
...
# Final -- value cannot be reassigned
MAX_RETRIES: Final = 3
API_URL: Final[str] = "https://api.example.com"
MAX_RETRIES = 5 # mypy error: cannot assign to Final
# Final classes and methods
from typing import final
@final
class Singleton:
"""Cannot be subclassed."""
...
class Base:
@final
def critical_method(self) -> None:
"""Cannot be overridden in subclasses."""
...
# ClassVar -- class-level attributes, not per-instance
class Connection:
max_connections: ClassVar[int] = 100 # shared across all instances
timeout: ClassVar[float] = 30.0
host: str # instance attribute
10. Type Narrowing and Type Guards
Type narrowing lets the type checker understand conditional logic. TypeGuard and TypeIs create custom narrowing functions:
# Built-in narrowing -- mypy understands these automatically
def process(value: str | int) -> str:
if isinstance(value, str):
return value.upper() # mypy knows value is str here
else:
return str(value) # mypy knows value is int here
# None narrowing
def greet(name: str | None) -> str:
if name is None:
return "Hello, stranger!"
return f"Hello, {name}!" # mypy knows name is str
# TypeGuard -- custom type narrowing function (Python 3.10+)
from typing import TypeGuard
def is_string_list(items: list[object]) -> TypeGuard[list[str]]:
"""Narrows list[object] to list[str] when True."""
return all(isinstance(item, str) for item in items)
def process_items(items: list[object]) -> None:
if is_string_list(items):
# mypy knows items is list[str] here
print(", ".join(items))
# TypeIs -- stricter narrowing (Python 3.12+)
from typing import TypeIs
def is_positive_int(value: int | str) -> TypeIs[int]:
"""Narrows to int in both branches."""
return isinstance(value, int) and value > 0
def handle(value: int | str) -> None:
if is_positive_int(value):
print(value + 1) # value is int
else:
print(value) # value is str (TypeIs narrows both sides)
11. ParamSpec and Concatenate: Typing Decorators
ParamSpec (PEP 612) captures entire function signatures, solving the long-standing problem of typing decorators that preserve parameter types:
from typing import Callable, ParamSpec, TypeVar, Concatenate
from functools import wraps
P = ParamSpec("P")
R = TypeVar("R")
# Basic decorator that preserves the signature
def logged(func: Callable[P, R]) -> Callable[P, R]:
@wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
print(f"Calling {func.__name__}")
return func(*args, **kwargs)
return wrapper
@logged
def add(a: int, b: int) -> int:
return a + b
# Type checker sees: add(a: int, b: int) -> int -- signature preserved!
# Concatenate -- when the decorator adds or removes parameters
def with_db(func: Callable[Concatenate[Database, P], R]) -> Callable[P, R]:
"""Injects a Database as the first argument."""
@wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
db = get_database()
return func(db, *args, **kwargs)
return wrapper
@with_db
def get_user(db: Database, user_id: int) -> User:
return db.query(User, user_id)
# After decoration: get_user(user_id: int) -> User
# The db parameter is hidden -- injected by the decorator
# Python 3.12+ syntax
def logged[**P, R](func: Callable[P, R]) -> Callable[P, R]:
@wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
print(f"Calling {func.__name__}")
return func(*args, **kwargs)
return wrapper
12. Self Type (PEP 673)
The Self type, introduced in Python 3.11, correctly types methods that return the current class, including in subclasses:
from typing import Self
class Builder:
def __init__(self) -> None:
self._config: dict[str, str] = {}
def set(self, key: str, value: str) -> Self:
"""Returns Self so subclasses return their own type."""
self._config[key] = value
return self
def build(self) -> dict[str, str]:
return self._config.copy()
class AdvancedBuilder(Builder):
def set_advanced(self, key: str, value: str) -> Self:
self._config[f"adv_{key}"] = value
return self
# Without Self, mypy would think .set() returns Builder, not AdvancedBuilder
result = AdvancedBuilder().set("a", "1").set_advanced("b", "2").build()
# Self in classmethods
class Model:
@classmethod
def from_dict(cls, data: dict[str, str]) -> Self:
instance = cls()
for k, v in data.items():
setattr(instance, k, v)
return instance
13. mypy Configuration and Usage
mypy is the most widely used static type checker. Here is how to configure it for a project:
# pyproject.toml
[tool.mypy]
python_version = "3.12"
strict = true # Enable all strict checks
warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = true # All functions must have annotations
disallow_any_generics = true # No bare list, dict -- must be list[int]
check_untyped_defs = true
no_implicit_optional = true
# Per-module overrides for third-party libraries without stubs
[[tool.mypy.overrides]]
module = "redis.*"
ignore_missing_imports = true
[[tool.mypy.overrides]]
module = "celery.*"
ignore_missing_imports = true
# Install mypy
pip install mypy
# Check your code
mypy src/
mypy src/app.py --strict
# Install type stubs for popular libraries
pip install types-requests types-redis types-PyYAML
# Show error codes for targeted suppression
mypy src/ --show-error-codes
# Generate a report
mypy src/ --html-report mypy-report/
# Common flags for gradual adoption
mypy src/ --ignore-missing-imports --no-strict-optional
# Suppressing specific errors when needed
x = some_dynamic_thing() # type: ignore[assignment]
# Reveal inferred types during development
reveal_type(my_variable) # mypy prints the inferred type
# Cast when you know better than the checker
from typing import cast
raw_data: object = get_data()
user = cast(User, raw_data) # Trust me, this is a User
14. Runtime Type Checking with Pydantic
While mypy checks types statically, Pydantic validates data at runtime. This is essential for API input, configuration, and external data:
from pydantic import BaseModel, Field, field_validator
class User(BaseModel):
name: str = Field(min_length=1, max_length=100)
email: str
age: int = Field(ge=0, le=150)
tags: list[str] = []
# Pydantic validates at runtime using the type annotations
user = User(name="Alice", email="alice@dev.io", age=30)
user = User(name="", email="a@b.c", age=30) # ValidationError: min_length
# Works with type hints you already know
class Config(BaseModel):
host: str = "localhost"
port: int = 8080
debug: bool = False
allowed_origins: list[str] = ["*"]
db_url: str | None = None
# Custom validators use the same type system
class Order(BaseModel):
items: list[str]
total: float
@field_validator("total")
@classmethod
def total_must_be_positive(cls, v: float) -> float:
if v <= 0:
raise ValueError("Total must be positive")
return v
15. Common Patterns and Best Practices
# Pattern 1: Type aliases for readability
type UserId = int # Python 3.12+
type Headers = dict[str, str]
type Callback = Callable[[str, int], None]
# Pre-3.12 equivalent
from typing import TypeAlias
UserId: TypeAlias = int
Headers: TypeAlias = dict[str, str]
# Pattern 2: Overloaded functions -- different return types per input
from typing import overload
@overload
def parse(raw: str) -> dict[str, str]: ...
@overload
def parse(raw: bytes) -> list[int]: ...
def parse(raw: str | bytes) -> dict[str, str] | list[int]:
if isinstance(raw, str):
return {"data": raw}
return list(raw)
# Pattern 3: Generic with constraints
from typing import TypeVar
T = TypeVar("T", bound="Comparable")
class Comparable(Protocol):
def __lt__(self, other: Self) -> bool: ...
def min_value(a: T, b: T) -> T:
return a if a < b else b
# Pattern 4: Annotated for metadata (validators, docs, serialization)
from typing import Annotated
PositiveInt = Annotated[int, Field(gt=0)]
Email = Annotated[str, Field(pattern=r"^[\w.+-]+@[\w-]+\.[\w.]+$")]
# Pattern 5: Never type for functions that never return
from typing import Never, NoReturn
def fail(msg: str) -> Never:
raise RuntimeError(msg)
def infinite_loop() -> NoReturn:
while True:
pass
16. Gradual Typing: Adding Hints to Existing Code
- Start with public API functions — annotate function signatures in your most-used modules first.
- Use
mypy --stricton new code — add per-module overrides for legacy code. - Run mypy in CI — prevent regressions on typed modules while leaving untyped modules alone.
- Use
reveal_type()and--warn-return-anyto find hiddenAnyleaks. - Install type stubs for all third-party libraries:
pip install types-requestsetc. - Prefer Protocol over ABC when typing existing code that uses duck typing.
- Use
from __future__ import annotations(Python 3.7+) for forward references and modern syntax.
# Gradual: start with just the critical path
def create_order(
user_id: int,
items: list[dict[str, str | int]],
coupon: str | None = None,
) -> dict[str, str | int]:
...
# Then progressively use TypedDict, dataclass, or Pydantic models
class OrderItem(TypedDict):
product_id: str
quantity: int
price: float
class OrderResult(TypedDict):
order_id: str
status: str
total: float
def create_order(
user_id: int,
items: list[OrderItem],
coupon: str | None = None,
) -> OrderResult:
...
Frequently Asked Questions
What is the difference between type hints and runtime type checking?
Type hints are annotations that declare expected types but are not enforced at runtime. Python ignores them during execution. They exist for static analysis tools like mypy, pyright, and IDE autocompletion. Runtime type checking requires a library like Pydantic or typeguard that reads annotations and validates values during execution. Use both: type hints for development-time safety and Pydantic for validating external input.
Should I use list[int] or typing.List[int]?
Use lowercase list[int], dict[str, int], set[str], and tuple[int, ...] if targeting Python 3.9+. PEP 585 made built-in types directly subscriptable, so typing.List, typing.Dict, etc. are no longer needed. For Python 3.7-3.8 support, add from __future__ import annotations at the top of each file.
How do I type a function that can return None?
Use X | None in Python 3.10+ or Optional[X] in older versions. Note that Optional does not mean the parameter is optional in the function signature sense — it only means the type can be X or None. A parameter is optional when it has a default value, regardless of its type annotation.
What is the difference between TypeVar and Generic?
TypeVar creates a type variable representing an unknown-but-consistent type. Generic makes a class parameterizable by type variables. Define T = TypeVar("T") and then class Stack(Generic[T]) so users write Stack[int] or Stack[str]. In Python 3.12+, the new syntax class Stack[T] replaces both.
What is Protocol and how does it differ from ABC?
Protocol enables structural subtyping (duck typing for type checkers). A class satisfies a Protocol if it has the required methods, without explicitly inheriting from it. ABC uses nominal subtyping, requiring explicit inheritance. Protocol is ideal for accepting any object with certain methods without forcing third-party classes to change their inheritance hierarchy.
How do I set up mypy for a new project?
Install mypy with pip install mypy, then add a [tool.mypy] section to pyproject.toml. Start with strict = true for new projects. Run mypy src/ to check your code. Install type stubs for libraries with pip install types-requests types-redis. For existing projects, start without strict mode and enable flags incrementally.
Conclusion
Type hints have evolved from a niche feature into an essential part of professional Python development. Modern Python gives you expressive tools: int | str for unions, list[int] for generics, Protocol for structural subtyping, TypeGuard for custom narrowing, and ParamSpec for fully typed decorators. Combined with mypy for static checking and Pydantic for runtime validation, you get a type system that catches real bugs without sacrificing Python's dynamic nature.
Start with basic annotations on your public APIs, add mypy to CI, and progressively type deeper. The investment pays off in fewer bugs, better IDE support, and code that documents itself.
Related Resources
- Pydantic Complete Guide — runtime data validation using type annotations
- Python Decorators Complete Guide — ParamSpec and Concatenate in action
- Python Data Structures Guide — typing lists, dicts, sets, and tuples
- Python Asyncio Complete Guide — typing async functions and coroutines
- GitHub Merge Queue Escalation Decision Cutoff for Repeated ACK Breaches Guide — practical authority-transfer policy templates written as strongly typed operational contracts