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:

# 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.

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.

# 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
ignore_missing_imports = false

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.

# 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.

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.

# 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

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.

# 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.

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