mypy uses annotations to detect incompatible calls before execution. Python remains dynamic and can run despite analyzer errors, so typing complements tests rather than replacing them.

Configure pyproject.toml

[tool.mypy]
python_version = "3.13"
warn_unused_ignores = true
disallow_untyped_defs = true
no_implicit_optional = true
from collections.abc import Iterable


def total(values: Iterable[int]) -> int:
    return sum(values)

Run mypy src. In a legacy codebase, begin with domain modules and widely imported libraries. Prevent new untyped functions before attempting a complete migration. The type hints guide covers unions, generics, and protocols.

Look for maintained stub packages when a dependency lacks typing. Use # type: ignore[code] only on the necessary line with a reason, and enable warn_unused_ignores. Do not spread Any merely to silence diagnostics.

Run the same command in CI with repository-owned configuration. The tox tutorial shows how to orchestrate type checks and version matrices.

The official mypy documentation, accessed July 22, 2026, explains gradual adoption and strict mode. The goal is not to satisfy a tool at any cost, but to make important contracts explicit and reviewable.

Understand what mypy proves

mypy reasons about annotated values without executing the program. If a function accepts str, passing a known int can be reported before that path reaches production. The analyzer can also narrow a union after an isinstance check and verify that every returned value matches the declared result.

That guarantee has boundaries. Data arriving from JSON, environment variables, databases, and network clients still needs runtime validation. A type annotation does not convert or inspect external input. Tests remain responsible for behavior, integration, timing, and side effects. Use static analysis to make contracts consistent, then test whether the implementation fulfills those contracts.

Any creates another boundary. Operations involving it are largely accepted, so a value typed as Any can carry an error through several functions. This is sometimes necessary at an untyped interface, but it should not become the default annotation. Convert or validate unknown input near the boundary and return a precise domain type to the rest of the application.

Establish a practical baseline

Put the configuration in version control so editors, local commands, and CI use the same rules. Scope the first run to application and test directories that the team owns:

[tool.mypy]
files = ["src", "tests"]
python_version = "3.13"
show_error_codes = true
warn_unused_ignores = true
warn_redundant_casts = true
check_untyped_defs = true

check_untyped_defs examines the bodies of functions that lack complete annotations, but it does not make their interfaces fully typed. disallow_untyped_defs is the stronger next step. Enable it for new modules first if the existing error count is too large.

Run the command from the repository root. Configuration discovery and import paths can change when it runs from another directory. Pin mypy and relevant stub packages in the development dependency set, because analyzer and stub updates can legitimately uncover new errors. Review those changes deliberately rather than silently using a different local version.

Migrate an existing project by boundary

A useful migration unit is a coherent package, not an arbitrary number of errors. Begin with shared domain models or utilities that many modules call. Their annotations improve inference downstream. Add typed signatures to public functions, then work inward through helpers. Keep the main branch passing by defining per-module rules:

[[tool.mypy.overrides]]
module = "legacy_reports.*"
disallow_untyped_defs = false

[[tool.mypy.overrides]]
module = "billing.*"
disallow_untyped_defs = true
disallow_any_generics = true

The exception records where debt remains while protecting migrated code. Avoid a global ignore_errors, because it hides regressions along with the initial backlog. Give overrides a narrow module pattern and remove them as migration progresses.

Do not annotate everything as object or Any just to reach zero errors. object is safe but permits very few operations; Any permits almost everything and transfers risk. Describe the operations a caller needs with a protocol, or describe the stored values with a concrete union or generic collection.

Model optional values and narrowing

str | None means absence is a real case that callers must handle. It is different from a parameter with a default value. Make the check explicit and let mypy narrow the type:

def display_name(name: str | None) -> str:
    if name is None:
        return "Anonymous"
    return name.strip()

Avoid using assert value is not None unless absence truly represents an impossible state. Assertions can be disabled and can conceal an incomplete business rule. Prefer a conditional that returns, raises a meaningful exception, or establishes the invariant through validated construction.

For more involved validation, a TypeGuard can teach the analyzer about a user-defined predicate. Use it only when the predicate genuinely verifies every condition promised by the narrower type. A misleading guard is as dangerous as an unchecked cast because mypy trusts its declaration.

Use generics and protocols for reusable contracts

Generics preserve relationships between input and output. A function that returns the first element should retain the element type instead of returning object:

from collections.abc import Sequence
from typing import TypeVar

T = TypeVar("T")


def first(items: Sequence[T]) -> T:
    if not items:
        raise ValueError("items cannot be empty")
    return items[0]

Protocols provide structural contracts. A function can accept anything with the required methods without forcing application classes to inherit from a framework-specific base. Keep protocols small and centered on the consumer's needs. Large protocols are harder to implement and often reveal that a function has too many responsibilities.

Use cast() only when runtime logic has established information mypy cannot infer. A cast performs no runtime conversion or check. If the value is genuinely uncertain, validate it with isinstance, a parser, or a schema library instead.

Handle libraries and generated code

Many modern packages ship inline type information through py.typed; others rely on separately maintained stub distributions. Follow the library's official typing instructions and keep runtime packages distinct from development-only stubs. An outdated stub can disagree with the installed library, so update compatible pairs together.

If no types exist, prefer a local adapter with a precise interface. It confines uncertainty and makes later replacement easier. A per-module ignore_missing_imports override is safer than the global option:

[[tool.mypy.overrides]]
module = "untyped_vendor.*"
ignore_missing_imports = true

Generated files may deserve an explicit exclusion when they are reproducible and never edited manually. Do not exclude an entire source tree merely because one generated module is noisy.

Diagnose errors instead of suppressing them

Read the error code and trace the first place where the inferred type becomes too broad. reveal_type(value) is useful during investigation and must be removed afterward. Sometimes the best fix is an annotation on an empty collection, because mypy cannot infer its future element type:

user_ids: list[int] = []

When an ignore is unavoidable, target one diagnostic: # type: ignore[import-untyped]. Add a short explanation if the reason is not obvious. warn_unused_ignores will then identify suppressions that became obsolete after a dependency or annotation improved.

Do not respond to every incompatibility with a cast. Check whether the annotation exposes a real defect, such as returning None on an undocumented path, mutating a collection with the wrong value, or accepting a broader argument than the implementation supports.

Make CI strict and predictable

CI should invoke the same command developers run, with the same configuration and dependency lock. A dedicated tox environment can install mypy and stubs and run mypy src tests. Fail the job on diagnostics; do not pipe the command into an unconditional success.

For a gradual rollout, fix the current scope and prevent new errors there. Expanding one package at a time gives a visible completion criterion. Avoid comparing only a total error count, because one serious new error could replace several harmless old ones while the number decreases.

Editors can provide quick feedback, but CI remains the shared authority. Document the command in the contributor guide and keep it fast enough for routine use. Run tests alongside it: a green type check establishes consistency in the modeled contracts, not correctness of the running system.

A maintainable typing policy

Require annotations where they communicate stable boundaries: public functions, shared models, callbacks, and data transformations. Local variables usually benefit from inference unless an explicit type clarifies an empty collection or a deliberately broad interface.

Review typing changes as design changes. An unwieldy union may indicate mixed responsibilities; repeated optional checks may point to an invalid intermediate state; pervasive Any may reveal an unvalidated boundary. The valuable outcome is not the absence of red output. It is code whose inputs, outputs, and failure cases are easier for a person and a tool to understand.