Type hints and static checking

Annotating functions and data, unions and generics, Protocol for structural typing, and how to run mypy and read its errors.

Annotating your code

Annotations are ordinary objects attached to the function; Python does not enforce them at runtime. Their value is that a checker can find type errors before the code runs, and that editors can complete names accurately.

from collections.abc import Iterable, Sequence, Mapping

def total(prices: Sequence[float], *, tax: float = 0.0) -> float:
    return sum(prices) * (1 + tax)

def tag_counts(tags: Iterable[str]) -> dict[str, int]:
    counts: dict[str, int] = {}
    for tag in tags:
        counts[tag] = counts.get(tag, 0) + 1
    return counts

def first_name(user: Mapping[str, str]) -> str | None:
    return user.get("name")

x: int = 0
rows: list[tuple[str, int]] = []
lookup: dict[str, list[str]] = {}

# annotate local containers when the inferred type would be too narrow
results: list[str] = []

print(total([1.5, 2.5], tax=0.2))
HintMeaningNote
Sequence[float]Indexable, ordered, readableAccepts list and tuple
Iterable[str]Anything you can loop overThe loosest useful input type
dict[str, int]Mapping from str to intBuilt-in generics work from Python 3.9
str | NoneA string or nothingUse Optional[str] on older versions
Callable[[int], str]A function valueDescribe parameters and return
AnyOpt out of checkingEvery use hides real errors downstream

Unions, generics and Protocol

Narrow a union before using it: a checker understands isinstance, is None and truthiness tests and refines the type inside the branch.

from typing import Protocol, TypeVar

def render(value: int | str | None) -> str:
    if value is None:
        return ""                      # here the type is None
    if isinstance(value, int):
        return str(value)              # here it is int
    return value                       # here it is str

T = TypeVar("T")

def first_or(items: list[T], default: T) -> T:
    return items[0] if items else default

class SupportsClose(Protocol):         # structural: no inheritance needed
    def close(self) -> None: ...

def shutdown(resource: SupportsClose) -> None:
    resource.close()

shutdown(open("data.txt"))             # a file object satisfies the protocol

from dataclasses import dataclass

@dataclass
class Config:
    host: str
    port: int = 8000
    tags: list[str] = None             # type: ignore[assignment]

# modern alternative for mutable defaults
from dataclasses import field

@dataclass
class Better:
    tags: list[str] = field(default_factory=list)
💡
A Protocol describes what an object can do rather than what it is, which keeps libraries decoupled from your class hierarchy. Mark it @runtime_checkable only if you need isinstance checks against it.
  • Prefer narrowing over cast(); a cast silences the checker without proving anything.
  • Use field(default_factory=...) for mutable defaults in dataclasses.
  • Reserve Any and # type: ignore for boundaries you genuinely cannot type, and add a comment explaining why.

Running the checker

pip install mypy
mypy src/                      # basic run
mypy --strict src/             # maximum checking
mypy --install-types           # fetch stubs for third-party packages
python -m mypy --show-error-codes src/
[tool.mypy]
python_version = "3.11"
warn_unused_ignores = true
warn_redundant_casts = true
disallow_untyped_defs = true
ignore_missing_imports = true

[[tool.mypy.overrides]]
module = "legacy.*"
ignore_errors = true

Read errors bottom-up within a file: a wrong argument type on line 80 is often caused by an inferred list[object] created on line 20. Add the annotation at the source and most later errors disappear at once.

  • error: Argument 1 has incompatible type — the call site disagrees with the annotation.
  • error: Item "None" of "str | None" has no attribute — you skipped a None check.
  • error: Need type annotation — an empty container; add the explicit hint.
  • Assigning a check to CI without fixing the backlog first makes the whole team ignore it. Adopt per-package, using the overrides table above.

FAQ

Do type hints slow my program down?
No. They are evaluated at definition time and cost almost nothing; the checker runs separately. You can also postpone evaluation with from __future__ import annotations, which turns every annotation into a string.
mypy or pyright?
Both implement the same standard. mypy is the reference implementation and most widely configured; pyright is faster and ships with VS Code. Pick one as the source of truth in CI so the two never disagree in code review.

Testing with pytest Object-oriented Python: classes and dunder methods

Last refreshed 2026-09-18.