Enabling mypy strict Mode Incrementally

TL;DR — Don’t flip --strict on globally for a legacy codebase — it produces thousands of errors in one commit. Instead keep the global config lax, set strict = true inside [[tool.mypy.overrides]] for packages that are already clean, and ratchet the strict set outward one package at a time. --strict is just a bundle of individual flags, so you can also enable them one flag at a time for finer control.

--strict is not a single behavior; it is a curated set of a dozen stricter flags mypy turns on together. On a project written strict-first that is wonderful. On a project that grew up untyped, enabling it globally means every unannotated def, every implicit Any, and every untyped decorator lights up at once — an unmergeable wall of errors. The maintainable path is the same ratchet used for rolling out disallow_untyped_defs: lax globally, strict per module, expand.

Ratcheting strict overrides outward from a lax global baseline The global baseline stays lax while strict equals true overrides cover one package, then two, then the whole tree before the global flag flips on. Global: lax — strict overrides grow package by package Milestone 1 app.domain strict app.services lax app.legacy lax 1 package strict Milestone 2 app.domain strict app.services strict app.legacy lax 2 packages strict Milestone 3 app.domain strict app.services strict app.legacy strict flip global strict
Strict coverage grows package by package; once the whole tree is covered, the global flag flips and overrides retire.

What strict actually bundles

strict = true is shorthand. Under mypy 1.13 it enables, among others, these flags — knowing them lets you turn on one at a time instead of all at once. The bundle is not a fixed constant: mypy adds flags to it across releases (extra_checks joined the set in 1.8, strict_equality well before), so strict = true on a newer mypy is stricter than the same line on an older one. That is exactly why you should pin your checker (mypy==1.13.*) in the lockfile — a floating mypy can turn a green build red on an unrelated dependency bump because the bundle quietly grew a member.

The three families of flags inside strict = true The strict bundle splits into signature-annotation flags, Any-tightening flags, and cleanup or safety flags, each a separate column of individual toggles. strict = true is a dozen independent flags in three families Signature checks disallow_untyped_defs disallow_incomplete_defs disallow_untyped_calls disallow_untyped_decorators noisiest on legacy [no-untyped-def] Any-tightening disallow_any_generics warn_return_any disallow_subclassing_any strict_equality [type-arg] · [no-any-return] tightens implicit Any Cleanup / safety warn_unused_ignores warn_redundant_casts warn_unused_configs no_implicit_reexport extra_checks cheap, safe to enable early
The strict bundle is three families of independent flags; enable the cheap cleanup column first and the noisy signature column last.

Each flag maps to an error code you can grep for and suppress individually, which is what makes one-at-a-time adoption practical. The most important mappings: disallow_untyped_defs[no-untyped-def], warn_return_any[no-any-return], disallow_any_generics[type-arg] (a bare list where list[int] was expected), and strict_equality[comparison-overlap] (an == between types that can never be equal, e.g. bytes vs str). The cleanup family barely touches legacy code: warn_redundant_casts and warn_unused_configs fire only on your own config debt, and warn_unused_ignores becomes genuinely useful later, flagging every # type: ignore whose underlying error you’ve since fixed.

# pyproject.toml — Python 3.11, mypy 1.13 — the flags strict turns on
[tool.mypy]
python_version = "3.11"
# strict = true is equivalent to setting all of:
warn_unused_configs = true
disallow_any_generics = true
disallow_subclassing_any = true
disallow_untyped_calls = true
disallow_untyped_defs = true          # [no-untyped-def]
disallow_incomplete_defs = true
check_untyped_defs = true
disallow_untyped_decorators = true
warn_redundant_casts = true
warn_unused_ignores = true
warn_return_any = true                 # [no-any-return]
no_implicit_reexport = true
strict_equality = true                 # [comparison-overlap]
extra_checks = true

The two that generate the most noise on legacy code are disallow_untyped_defs ([no-untyped-def]) and warn_return_any ([no-any-return]). If you want the gentlest possible on-ramp, enable those two last.

One subtlety worth internalizing: config-file strict = true and the command-line --strict are the same bundle, but a flag set explicitly in config always overrides the bundle. So strict = true with warn_return_any = false on the next line is legal and means “everything strict enables, minus warn_return_any” — a common escape hatch while a package still returns Any from an untyped third-party call. Pyright does not share this vocabulary: its strict is typeCheckingMode = "strict" in pyproject.toml (or a # pyright: strict file comment), it keys off reportXxx rule names rather than mypy’s flags, and it enforces stricter None handling than mypy does out of the box. Don’t assume a module that is mypy-strict is pyright-strict; the two tools disagree most on Any-returning calls and on narrowing after assert. See Pyright vs Mypy Comparison for where they diverge.

Start global-lax

Keep the global section permissive so the existing build stays green. This is the baseline every module inherits until an override says otherwise. mypy resolves each module’s settings by starting from [tool.mypy] and then applying every matching [[tool.mypy.overrides]] block on top; keys you don’t set in an override fall through to the global value. That inheritance is the whole mechanism — a lax global plus a handful of strict overrides means “strict only where I said so, lax everywhere else.”

Modules inherit the lax global baseline until an override overrides it A single global tool.mypy node feeds three module nodes; two inherit the lax defaults unchanged while one has a strict override replacing specific keys. [tool.mypy] · strict = false global baseline every module inherits app.services inherits lax — no override untyped defs allowed app.legacy inherits lax — no override untyped defs allowed app.domain override: strict = true replaces the inherited keys
Every module inherits the lax global settings; an override changes only the keys it names, for only the modules its glob matches.
# pyproject.toml — lax global baseline, mypy 1.13
[tool.mypy]
python_version = "3.11"
strict = false
warn_unused_ignores = true    # a couple of cheap wins are safe globally
warn_redundant_casts = true   # also cheap; only flags your own casts
ignore_missing_imports = false

Even in the lax baseline, always set python_version explicitly. mypy otherwise defaults to the interpreter it runs under, so a runner on 3.12 and a laptop on 3.10 will disagree about whether X | None unions (PEP 604) or type subscripting are valid syntax in annotations. Pinning python_version makes the check reproducible regardless of the host interpreter. You do not need from __future__ import annotations (PEP 563) for mypy to see your hints — mypy reads annotations statically whether or not they are stringized; that import matters for runtime annotation cost, not for the checker. Leave ignore_missing_imports off globally: turning it on hides real “Cannot find implementation or library stub for module” errors in first-party code. Scope it per-package instead, exactly as optimizing mypy.ini for large codebases describes. Note also that show_error_codes is on by default since mypy 0.990, so you no longer need it in config — but you do rely on those codes being visible for the targeted # type: ignore[code] suppressions below.

If your project still configures mypy through mypy.ini or setup.cfg rather than pyproject.toml, the same baseline is a [mypy] global section with per-module [mypy-app.domain.*] sections instead of [[tool.mypy.overrides]] blocks; the flag names and their semantics are identical, only the container differs. One INI-only trap: a section-header typo like [mypy-app.domain*] (missing the dot before the star) silently matches nothing, so the override appears to do nothing at all and the module quietly keeps inheriting the lax global. Whichever format you choose, keep exactly one config file on mypy’s search path — it reads the first of mypy.ini, .mypy.ini, pyproject.toml, setup.cfg it finds and ignores the rest, so a stray setup.cfg can shadow the pyproject.toml you think you are editing.

Set strict per clean package

Pick a package whose annotations are already complete — a domain or model layer is usually cleanest — and turn strict on for it alone with an override matched by module glob. The module key matches against the dotted module path, not the filesystem path: "app.domain.*" covers app.domain.models and app.domain.services.pricing but not app.domain itself, so list both "app.domain" and "app.domain.*" if the package has code directly in its __init__.py.

Choosing strict = true versus listing individual flags per package A decision diamond asks whether the package passes every strict flag; yes collapses to strict = true, no lists individual flags and loops back after fixing one flag. Package passes every strict flag? yes strict = true collapse the override no list flags individually one reviewable PR each fix one flag's errors, then re-ask
List individual flags while a package still fails any of them; collapse the override to strict = true only once it passes every one.
# pyproject.toml — strict only for app.domain
[[tool.mypy.overrides]]
module = "app.domain.*"
disallow_untyped_defs = true
disallow_incomplete_defs = true
warn_return_any = true
warn_no_return = true
# equivalently, once the package is fully clean:
#   strict = true

Prefer listing the individual flags at first; a package that passes disallow_untyped_defs may still trip warn_return_any, and enabling flags one at a time keeps each PR small and reviewable. Once a package is spotless, collapse the list to strict = true for that override.

A caveat that surprises people: an override cannot make a module strict if the code it imports is untyped in a way that leaks Any back in. warn_return_any fires when a function annotated -> int actually returns a value mypy only knows as Any — commonly the result of an untyped third-party call. So a “clean” package can still light up [no-any-return] because of what crosses its boundary. Two fixes: install the stub package (pip install types-requests) so the call is typed, or narrow at the boundary with an explicit cast:

# app/domain/pricing.py — Python 3.11, mypy 1.13, strict override on app.domain
from typing import cast
import untyped_lib  # no stubs -> everything from it is Any

def unit_price(sku: str) -> int:
    raw = untyped_lib.lookup(sku)        # inferred as Any
    return cast(int, raw)                # without cast: error [no-any-return]

Per-package strictness is a mypy idea specifically. Pyright has no [[overrides]] table; you scope it with executionEnvironments in pyrightconfig.json/pyproject.toml or a per-file # pyright: strict comment. If your repo gates on both checkers, plan to express the same package boundaries twice, once per tool.

Ratchet outward

Every iteration is the same: clean the next package, add (or upgrade) its override, merge. Overrides stack, so the strict set only ever grows. Because the enforced set is monotonic — it never shrinks between merges — the migration is a ratchet in the literal sense: each PR advances coverage and nothing you’ve locked in can silently regress, since a new untyped def in a covered package fails CI on the spot.

Strict coverage ratchets up and never regresses A horizontal bar of eight package cells fills from left to right across three merges; earlier fills stay filled, so coverage only grows. Packages under a strict override — the fill only advances PR 1 2 / 8 PR 4 4 / 8 PR 9 8 / 8 — flip global strict
Each merge advances the covered fraction and locks it; the bar can only move right, which is what makes the rollout safe to do over many small PRs.
# pyproject.toml — strict set now covers two packages
[[tool.mypy.overrides]]
module = "app.domain.*"
strict = true

[[tool.mypy.overrides]]
module = "app.services.*"
disallow_untyped_defs = true    # services not fully strict yet
warn_return_any = true

Watch override precedence: when two blocks match the same module, the last matching block wins for any key they both set. If a broad module = "app.*" lax block sits below a specific module = "app.domain.*" strict block, the broad one silently re-loosens app.domain. The rule of thumb is to keep the most specific globs last so nothing downstream undoes them, and to avoid broad catch-all overrides entirely during a ratchet. You can watch the enforced set grow directly from the config — the number of override blocks carrying disallow_untyped_defs = true (or strict = true) is a progress bar that lives in code review, and mypy --strict app/ 2>&1 | grep -c '\[no-untyped-def\]' on a schedule charts the remaining work trending to zero. The same package-boundary discipline scales to multi-repo setups in monorepo incremental typing.

Before you promote a package’s override from an explicit flag list to strict = true, do a dry run that previews the delta: mypy --strict app/domain/ 2>&1 | grep -c error counts how many diagnostics the full bundle adds beyond the flags you already enforce. Zero means the collapse to strict = true is safe; a non-zero count is usually [type-arg] from bare generics (dict where dict[str, int] was meant) or [no-any-return] from a boundary call into untyped code, both of which the surrounding sections show how to close. Running that one-liner in scheduled CI turns “is this package ready to graduate?” into a number instead of a judgment call, and it catches the case where a new strict flag added in a mypy upgrade would regress a package you thought was done.

Baseline the rest

For code you can’t clean immediately, don’t discard the errors — freeze them. Silence a specific line with a coded ignore so any new error of a different kind still surfaces, and let warn_unused_ignores flag the comment once the underlying issue is fixed. The distinction that matters is freeze versus suppress: a frozen error is one you’ve recorded and will fix later while CI blocks any brand-new error; a suppressed error is one you’ve hidden forever. A coded ignore plus warn_unused_ignores gives you the first; a bare # type: ignore gives you the second.

Baseline gate: frozen errors pass, new errors fail Two error streams reach a baseline gate; pre-existing errors recorded in the baseline pass, while a newly introduced error is rejected and fails CI. Pre-existing errors recorded in baseline New error introduced by this PR Baseline gate diff vs snapshot Pass ✓ frozen — not counted Fail CI ✗ blocks the merge
A baseline records the current error set so CI fails only on newly introduced errors; the frozen ones pass through untouched.
# app/legacy/report.py — Python 3.11, mypy 1.13
totals = aggregate(rows)  # type: ignore[no-any-return]  # remove when aggregate() is typed

A generated baseline file (or a tool like mypy-baseline) captures the full snapshot of pre-existing errors so CI fails only on newly introduced ones. The monorepo incremental typing guide extends this to multi-package repos.

Runtime vs static analysis Every flag in the strict bundle changes only what mypy reports — none of them alter runtime behavior. A module that just gained strict = true runs byte-for-byte as before; the annotations you add to satisfy [no-untyped-def] are inert metadata on __annotations__ unless something calls typing.get_type_hints. Enabling strict can never break production; it can only fail CI.

Common mistakes

  • Big-bang strict = true globally. On legacy code this floods CI with [no-untyped-def], [no-any-return], and [type-arg] at once, blocking every merge. Scope it with overrides instead.
  • A too-broad override glob. module = "app.*" with strict = true re-creates the flag-day problem. Match the specific subpackage you actually finished.
  • Bare # type: ignore instead of coded. An uncoded ignore masks every future error on that line, including new bugs. Always write # type: ignore[no-any-return] so unrelated regressions still fail.
  • Forgetting override precedence. When two override blocks match a module, the last one wins. Keep the most specific globs last, or a broad lax block will silently undo a strict one.
Strict-rollout mistakes mapped to their fixes Four common mistakes when enabling strict mode are each paired with the corrective practice that avoids the failure. Mistake Fix global strict = true on day one floods CI with [no-untyped-def] scope with per-package overrides ratchet outward module = "app.*" with strict re-creates the flag-day problem match the finished subpackage narrow glob only bare # type: ignore masks every future error too # type: ignore[no-any-return] new errors still surface broad lax block after a strict one last match silently re-loosens keep specific globs last precedence preserved
Each rollout mistake on the left is paired with the corrective practice on the right that keeps CI green.

FAQ

Should I use strict = true per override or list flags individually? List flags individually while a package still has failures, so each flag lands in its own reviewable PR. Collapse to strict = true only once the package passes every strict flag — that keeps the override honest and future-proof against new flags the bundle adds.

How do I stop new code from regressing while old code is still lax? Enforce strict on the packages where new code lands, and use a baseline file so CI blocks only newly introduced errors in the not-yet-clean packages. New violations fail; the frozen legacy set doesn’t.

Back to mypy Configuration & Strictness