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