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.
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.
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 = trueglobally. 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.*"withstrict = truere-creates the flag-day problem. Match the specific subpackage you actually finished. - Bare
# type: ignoreinstead 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.