Skip to content
← All articles

Beyond --strict: optional error codes and mypy 2.0's changed defaults

A mypy major upgrade that changes inference defaults produces a wall of new errors. Knowing which four flags caused it turns a week into an afternoon.

--strict is thirteen flags. It is a good default and it is not a ceiling — and it is worth knowing exactly what it does not include, because two of the most useful checks in the tool are outside it.

--strict does not include --warn-unreachable or --warn-unused-configs, despite both appearing on several “strict flags” blog lists. It also does not flag deprecated typing spellings — List, Dict, Optional pass cleanly, even with --enable-error-code deprecated. Modernisation is ruff’s job, not mypy’s. And it does not validate the contents of __all__; a name listed there that does not exist is not a mypy error, though pyright does check it.

Knowing the boundaries is what stops “we’re on strict” from being mistaken for “we’re covered”.

mypy 2.0, released 2026-05-06

Four default changes account for almost every “the upgrade produced 400 new errors” report:

--local-partial-types is on by default. It changes how a name assigned in one scope and used in another is inferred. Code that relied on mypy joining partial types across scopes now needs an explicit annotation. This is the single largest source of new errors, and the fix is almost always adding the annotation the code should have had.

--strict-bytes is on by default. Per PEP 688, bytearray and memoryview are no longer implicitly assignable to bytes. This finds real bugs — a function that annotates bytes and is handed a bytearray behaves differently on mutation and on hashing — and it produces a burst of errors in any codebase doing binary I/O.

--allow-redefinition now means what --allow-redefinition-new meant. If you had the old flag in your config, its semantics changed under you.

--python-version 3.9 is rejected; the minimum target is 3.10. Legacy bundled stubs are gone, so packages that used to typecheck against mypy’s own copies now need the corresponding types-* distribution.

One addition rather than a change: experimental parallel checking via -nN, reported at up to roughly 5× on eight workers. Worth trying on a large codebase where the type-check job is the slow part of CI.

💡Your team upgrades mypy in the same pull request as a feature. CI is red with 200 errors. What went wrong before a single line of code was written? click to reveal

The pull request cannot be reviewed, and it cannot be bisected.

Every one of those 200 errors is either a pre-existing latent problem the new defaults exposed, or a genuine mistake in the new feature — and there is no way to tell which from the diff, because the same commit changed both the code and the rules being applied to it. Reviewers cannot judge the feature; the author cannot judge which fixes are real.

The discipline is: never upgrade a checker in the same PR as a behaviour change. Land the upgrade alone, with the errors fixed or explicitly deferred per-module, and let the diff be exactly “adapt to the new rules”. Then land the feature against a stable baseline.

The same holds for enabling a new error code, for a ruff major bump, and for a formatter change. Each is a mechanical, boring, reviewable-by-diffstat commit, and each becomes unreviewable the moment it is mixed with intent.

The optional codes worth enabling

[tool.mypy]
strict = true
warn_unreachable = true
warn_unused_configs = true
enable_error_code = [
  "ignore-without-code",
  "possibly-undefined",
  "redundant-expr",
  "truthy-bool",
  "truthy-iterable",
  "unused-awaitable",
  "explicit-override",
  "mutable-override",
  "deprecated",
  "exhaustive-match",
  "narrowed-type-not-subtype",
]

Four of those earn their place immediately.

unused-awaitable is the highest-value single flag in the list. A forgotten await produces a coroutine object that is truthy, never runs, and emits a RuntimeWarning that nobody reads. It is a silent no-op in production — the write never happened, the request was never sent — and this code turns it into a compile-time error.

ignore-without-code requires every # type: ignore to name what it is ignoring: # type: ignore[arg-type]. A bare ignore silences errors nobody intended, including ones introduced years later on the same line.

possibly-undefined catches the name bound only inside an if branch and read after it — the classic for-loop-variable-used-after-the-loop bug that happens to work until the iterable is empty.

explicit-override requires @override on every method that overrides a base-class method. It turns “I renamed the base method and three subclasses silently stopped overriding anything” from a production defect into an error.

Adoption, in the order that works

Land explicit-override with a codemod, not by hand. It fires on every override in the codebase — hundreds of them. Generate the decorators mechanically, land that as one no-behaviour-change commit, then flip the flag on.

Scope truthy-bool and redundant-expr per-module. Both find real defects — if some_object: where some_object is always truthy, a condition that is statically always true — and both fire on a lot of idiomatic-looking code. Enable them for new packages, then work backwards.

[[tool.mypy.overrides]]
module = ["myapp.legacy.*"]
disable_error_code = ["truthy-bool", "redundant-expr"]

Pin the mypy version in a [dependency-groups] entry. mypy’s own documentation warns that the --strict set may change between releases, and mypy 2.0 is the proof. An unpinned checker means your gate can change without a commit.

💡warn_unreachable reports unreachable code in a function you are certain runs. Is mypy wrong? click to reveal

Almost never. There are two common causes, and both are findings.

A narrowing you did not notice. An earlier isinstance check, an assert, or a return inside a branch has narrowed a type to Never on the path in question. mypy is telling you that, according to the annotations, that branch cannot be entered — so either the annotation is wrong or the branch is dead.

An annotation that is a lie. A function annotated -> str that can return None makes every if result is None: at the call site unreachable. The code runs and the check does fire at runtime; what mypy is reporting is that your declared types and your actual behaviour disagree, which is exactly the bug you want found.

The genuine false-positive case exists — code reachable only under a different platform or Python version, where mypy is analysing one configuration — and the honest fix there is a scoped, coded ignore with a comment, not turning the flag off. Unreachable code that is not a false positive is either dead weight or a broken assumption, and both are worth a commit.