Python Type Hints: The Complete Guide for 2026

February 12, 2026 · 20 min read

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:

# 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

  1. Start with public API functions — annotate function signatures in your most-used modules first.
  2. Use mypy --strict on new code — add per-module overrides for legacy code.
  3. Run mypy in CI — prevent regressions on typed modules while leaving untyped modules alone.
  4. Use reveal_type() and --warn-return-any to find hidden Any leaks.
  5. Install type stubs for all third-party libraries: pip install types-requests etc.
  6. Prefer Protocol over ABC when typing existing code that uses duck typing.
  7. 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.

⚙ Keep learning: Validate data at runtime with the Pydantic Complete Guide, type your decorators with the Python Decorators Guide, and explore Python Asyncio for typing async code. For incident policy modeling with strict ownership gates, see Merge Queue Escalation Decision Cutoff Guide.

Related Resources

Related Resources

Pydantic Complete Guide
Runtime data validation using Python type annotations
Python Decorators Guide
ParamSpec and Concatenate for fully typed decorators
Python Data Structures
Typing lists, dicts, sets, tuples, and custom containers
Python Asyncio Guide
Typing async functions, coroutines, and event loops
Merge Queue Escalation Decision Cutoff Guide
Policy contracts for repeated ACK timeout breaches with deterministic authority handoffs.