Mypy Configuration & Strictness: A Practical Guide for Python Teams

Establishing a robust type-checking pipeline requires balancing developer velocity with rigorous static analysis. This guide details progressive strictness adoption, configuration file architecture, and CI enforcement strategies.

Define a baseline Static Analysis Tools & CI Integration strategy before enforcing strict type checking. Adopt incremental strictness to prevent build failures during legacy code migration. Leverage modern pyproject.toml standards for centralized configuration management.

mypy progressive strictness ladder Four steps of increasing mypy strictness: warn_return_any, check_untyped_defs, disallow_untyped_defs, and finally strict = true. Step 1 warn_return_any Step 2 check_untyped_defs Step 3 disallow_untyped_defs Step 4 strict = true Increasing strictness →
Adopt mypy strictness incrementally: start with warn_return_any, layer in additional flags, and graduate to strict = true once legacy modules are annotated.

Baseline Configuration Architecture

mypy resolves its configuration from a single file, not a merge of several. It looks for an explicit --config-file first, then mypy.ini, .mypy.ini, pyproject.toml, and finally setup.cfg in the current directory, and it stops at the first file it finds — a leftover setup.cfg can therefore silently shadow the pyproject.toml you meant to edit. Settings live in one global table ([tool.mypy] in TOML, [mypy] in INI) plus any number of per-module overrides ([[tool.mypy.overrides]] arrays of tables, or [mypy-<glob>] INI sections). Every module inherits the global defaults and then has each matching override layered on top; when more than one override matches a module, the last block wins, so ordering is significant rather than cosmetic.

Per-module config resolution and override precedence Global defaults feed into a module's effective configuration while two override blocks layer on top and the last matching block wins. Effective config for app.core.models = global + last matching override Global [tool.mypy] strict = false warn_return_any = true inherited baseline override "app.*" disallow_untyped_defs=true override "app.core.*" strict = true (last, wins) Effective config strict = true
A module's effective settings are the global defaults with matching overrides applied in order — the last matching [[tool.mypy.overrides]] block wins.

The python_version key is the single most consequential setting. It tells mypy which interpreter semantics to assume, which sys.version_info branches are live, and which typeshed stubs to load. Set it explicitly so a developer on 3.12 and a CI runner on 3.10 get identical diagnostics rather than diverging over, say, whether str.removeprefix (3.9+) or tomllib (3.11+) exists. Configure ignore_missing_imports, warn_return_any, and warn_unused_ignores next for safe initial adoption, and map module-specific overrides using [[tool.mypy.overrides]] to isolate third-party library gaps rather than loosening the whole project.

[tool.mypy]
python_version = "3.11"
strict = true
disallow_subclassing_any = true
warn_unreachable = true

[[tool.mypy.overrides]]
module = ["untyped_lib.*", "legacy_module.*"]
ignore_errors = true
disallow_untyped_defs = false

In the block above, disallow_subclassing_any rejects class C(Base) when Base resolves to Any (usually an untyped import), closing a silent hole where every attribute access on C would otherwise type-check as Any. warn_unreachable flags statically dead branches — it frequently surfaces a mistaken isinstance narrowing, or an if x is None: block after x was already proven non-None. The override turns error reporting off for untyped_lib and legacy_module via a module-glob list; ignore_errors = true suppresses every diagnostic in those packages while you migrate them, without changing anything for first-party code.

This configuration demonstrates centralized TOML management: it enables strict mode globally while isolating legacy dependencies via targeted overrides. TOML configuration has been fully supported since mypy 0.900, and [[tool.mypy.overrides]] since 0.910. Pin a recent release such as mypy>=1.15 in your lockfile so behavior is reproducible — new minor versions routinely tighten inference and introduce error codes that can turn a green branch red. For the flag-by-flag mechanics of scoping strictness this way, see rolling out disallow_untyped_defs incrementally and optimizing mypy.ini for large codebases.

Progressive Strictness Tuning

strict = true is not a single check but an umbrella that enables roughly a dozen flags at once: disallow_untyped_defs, disallow_incomplete_defs, disallow_untyped_calls, disallow_untyped_decorators, disallow_any_generics, check_untyped_defs, warn_return_any, warn_redundant_casts, warn_unused_ignores, no_implicit_reexport, strict_equality, and extra_checks. Turning all of them on at once against a codebase that grew up untyped produces hundreds of errors in a single commit, so the durable path is to enable them one at a time, lowest-noise first, and to scope each with per-module overrides until the whole tree is clean.

mypy --strict versus pyright strict: same checks, different rule names A four-row matrix pairing each strictness check with the mypy flag or error code and the equivalent pyright report rule. Same checks, two vocabularies Check mypy --strict pyright strict Unannotated def [no-untyped-def] reportUnknownParameterType Returns Any [no-any-return] reportUnknownVariableType Implicit Optional not allowed (X | None) reportOptionalMemberAccess Unreachable code warn_unreachable reportUnreachable
mypy and pyright enforce the same strictness ideas under different names; reconcile the two vocabularies when a repo runs both checkers.

The two flags that generate the most output are disallow_untyped_defs (error code [no-untyped-def], one per unannotated signature) and warn_return_any ([no-any-return], raised when a function annotated to return a concrete type actually returns an Any). check_untyped_defs is different in kind — it requires no annotations at all, it merely tells mypy to type-check inside bodies that lack them, which is a cheap early win. disallow_incomplete_defs is the gentle middle ground: it accepts a fully bare def f(x): but rejects a half-annotated def f(x: int, y):. Enabling check_untyped_defs and warn_return_any first, then disallow_incomplete_defs, then disallow_untyped_defs, keeps each PR reviewable.

from typing import TypeVar, Generic, Callable

T = TypeVar("T")

class Container(Generic[T]):
    def __init__(self, value: T) -> None:
        self.value = value

    def transform(self, func: Callable[[T], T]) -> T:
        reveal_type(self.value)  # note: Revealed type is "T`1"
        return func(self.value)

# Strict mode will flag missing type hints on legacy functions
def process_data(raw: dict) -> list:  # type: ignore[no-untyped-def]
    return list(raw.values())

reveal_type() and reveal_locals() are checker-recognized pseudo-calls: mypy prints Revealed type is "..." at analysis time and treats the call as a no-op for typing. Historically the name was undefined at runtime, so you had to delete it before running the program; since Python 3.11, typing.reveal_type exists as a real function (it returns its argument and writes to stderr), and typing_extensions.reveal_type backports it to older versions, so the debugging line no longer crashes production.

Pyright expresses the same spectrum through one typeCheckingMode setting with the values off, basic, standard, and strict, refined by individual reportX rules. A notable default difference: pyright infers and reports unknown types aggressively and treats implicit Optional as an error even in lighter modes, whereas mypy long ago dropped implicit Optional (PEP 484’s original behavior) and now requires you to spell X | None — the PEP 604 union syntax available unquoted from Python 3.10, or via from __future__ import annotations earlier. mypy’s strict flag enables over a dozen checks simultaneously including disallow_untyped_defs, warn_return_any, and disallow_any_generics; Ruff, by contrast, focuses on syntax and style and leaves deep type inference to dedicated checkers. Start with strict = false, enable warn_return_any and check_untyped_defs first, and reference Pyright vs Mypy Comparison when selecting a baseline for mixed-type environments. The module-by-module rollout is detailed in enabling mypy strict mode incrementally.

Two flags in the bundle deserve special mention because they catch bugs unrelated to missing annotations. disallow_untyped_calls flags a call into an unannotated function from annotated code, stopping Any from leaking across an otherwise well-typed boundary even when the caller itself is fully typed — which is why a “clean” module can still fail the moment one of its dependencies is not. no_implicit_reexport changes what a module is considered to export: with it on, from .models import User inside a package __init__.py does not re-export User to downstream importers unless you list it in __all__ or write the redundant-looking from .models import User as User. That one surprises teams the first time they turn on strict, because previously-working from mypackage import User lines suddenly raise [attr-defined] until the re-export is made explicit.

CI Pipeline Integration & Pre-commit Workflows

Type checking belongs at two gates: a local pre-commit hook for fast feedback, and a CI job that re-runs the identical configuration so nothing merges unchecked. The mirrors-mypy hook runs mypy in an isolated virtual environment, which is exactly why third-party stub packages must be declared in additional_dependencies rather than assumed present — the hook cannot see your project’s site-packages, so a stub that works locally will report Cannot find implementation or library stub inside the hook unless you list it.

One config, two gates: local pre-commit and CI A commit passes through a local pre-commit mypy hook using the cache, then the same check reruns in CI across all files before the pull-request gate blocks or allows the merge. One config, two gates — local hook and CI both run mypy git commit pre-commit: mypy .mypy_cache unchanged → skip CI job run --all-files PR gate error → block
The same mypy configuration runs at commit time and again in CI; caching keeps the local hook fast while --all-files guarantees the merge gate is complete.

Automate type checking as a blocking gate and pin the hook rev to an exact tag — leaving it floating lets a mypy release turn a green branch red with no code change. Pass --show-error-codes so every diagnostic carries the [code] you need for scoped ignores, and rely on --incremental (on by default) to reuse the .mypy_cache. Because pre-commit passes only staged files by default, set pass_filenames: false for type checkers: mypy needs the whole import graph to resolve cross-module types, and a partial file list produces false negatives. Implement the same hooks locally that run in CI so violations are caught before code reaches the main branch, and synchronize rules with Ruff Linter Integration to avoid redundant linting overhead.

repos:
  - repo: https://github.com/pre-commit/mirrors-mypy
    rev: v1.17.0
    hooks:
      - id: mypy
        args: [--config-file=pyproject.toml, --show-error-codes, --incremental]
        additional_dependencies: [types-requests, types-PyYAML]

This hook binds mypy to pre-commit with explicit type stubs and error-code visibility. In CI, cache the .mypy_cache directory keyed on your lockfile hash so it invalidates exactly when dependencies (and their stubs) change; a stale cache after an upgrade is a common source of phantom errors. On large repositories the dmypy daemon keeps the module graph resident between runs for near-instant re-checks, and --jobs N parallelizes the initial cold check — most effective on a wide module graph rather than a deep sequential one. Map mypy’s non-zero exit code straight to the job status (continue-on-error: false) so a type regression blocks the merge, targeting sub-5-minute execution on most codebases. The companion guides Pre-commit Hooks Setup and automating pre-commit type validation cover hook isolation and CI parity in depth.

Debugging & Error Suppression Strategies

When mypy reports something you believe is wrong, resist the reflex to silence it. Work down a fixed ladder: confirm whether it is a genuine bug, then whether the type is merely too wide or too narrow for mypy to prove what you know, and only then reach for suppression — and when you do, make it scoped and coded. reveal_type(x) is the fastest way to see what mypy actually inferred at a point before deciding how to fix it, and it usually explains a surprising error outright.

Decision ladder for resolving a mypy error Starting from a reported error, decide whether it is a real bug to fix, a type mypy cannot prove that a cast resolves, or a case for a scoped coded ignore that warn_unused_ignores later retires. mypy reports an error Genuine bug? yes → fix the code Type too wide / wrong? yes → cast() / assert_type() no → # type: ignore[error-code] no no
Only genuinely unprovable cases reach the bottom rung; a scoped [error-code] ignore plus warn_unused_ignores keeps suppressions honest and self-retiring.

Always write # type: ignore[error-code] rather than a bare # type: ignore. A bare ignore hides every present and future error on that line, so a real bug introduced later slips through unnoticed; a coded ignore suppresses only that one category. Turn on warn_unused_ignores so the moment the underlying issue is fixed, the now-pointless comment is itself flagged and you can delete it — ignores become self-retiring rather than accumulating as dead debt. Utilize reveal_type() for on-demand inspection of complex inference chains, and reach for Optimizing mypy.ini for large codebases when suppression volume is really a performance or scoping problem in disguise.

import json
from typing import Any, cast

def load_config(path: str) -> dict[str, Any]:
    with open(path) as f:
        data: Any = json.load(f)
    # cast documents the asserted type and keeps inference flowing
    return cast(dict[str, Any], data)

Prefer typing.cast(T, value) over an ignore when you know a value’s type but mypy cannot prove it: cast records the asserted type and keeps downstream inference working, whereas an ignore merely drops the error and leaves the expression as Any. Since Python 3.11 (and via typing_extensions earlier) you can also write assert_type(value, int), which fails the check if mypy’s inferred type is not exactly int — invaluable in tests that pin down inference. pyright uses its own suppression syntax alongside mypy’s: # pyright: ignore[reportGeneralTypeIssues] for a single rule, a bare # pyright: ignore for all, and it also honors # type: ignore, so a line in a dual-checker repo occasionally needs both.

Don't enable strict mode on day one Activating strict = true globally on an existing codebase immediately fails hundreds of checks and typically leads to widespread blanket # type: ignore abuse that defeats static analysis entirely. Use per-module overrides and the progressive ladder above instead.

Avoid enabling strict mode on day one for legacy repositories. It causes immediate CI failures across many files and typically triggers widespread blanket # type: ignore abuse that defeats static analysis entirely. Neglecting incremental mode and cache directories forces full AST parsing on every commit, which can increase pipeline execution time by 3-5x on large codebases — so treat suppression and caching as two halves of the same discipline: suppress narrowly, cache aggressively, and let warn_unused_ignores walk the debt back down over time.

FAQ

Should I use mypy.ini or pyproject.toml for configuration? Use pyproject.toml for modern Python projects to centralize build, lint, and type-checking settings in a single manifest file. mypy.ini is still fully supported and may be preferable for projects that need to share mypy config without a full pyproject.toml setup.

How do I safely enable strict mode on an existing codebase? Start with strict = false, enable individual strict flags incrementally (e.g., warn_return_any, then disallow_untyped_defs), and use [[tool.mypy.overrides]] to exclude legacy modules until refactored.

Why does mypy report “Cannot find implementation or library stub” for third-party packages? The package lacks inline type hints or external stubs. Install types-<package> from PyPI or set ignore_missing_imports = true for that specific module via [[tool.mypy.overrides]] rather than globally.

Back to Static Analysis Tools & CI Integration