Someone says “we’re on strict” in a design review and everybody nods. Nobody asks which checks that turns on, because everybody assumes it turns on all of them. It does not. --strict is a fixed bundle of thirteen other flags, and it is missing at least two that most people are certain are inside it.
This matters for a boring, practical reason. When mypy rejects your code, the diagnostic ends in a bracketed error code, and your next move — fix it, scope an ignore to it, or argue that the check itself is wrong for this codebase — depends entirely on knowing which flag put that check there. You cannot reason about an error you cannot attribute.
Here is the bundle, verified against mypy 2.3.
The thirteen
| Flag | What it rejects |
|---|---|
--disallow-untyped-defs |
Any def missing annotations |
--disallow-incomplete-defs |
A def annotated in part — three parameters typed, one not |
--check-untyped-defs |
(not a rejection) Type-checks the bodies mypy would otherwise skip entirely |
--disallow-untyped-calls |
An annotated function calling an unannotated one |
--disallow-untyped-decorators |
An annotated function wrapped by an unannotated decorator |
--disallow-subclassing-any |
class Handler(SomethingUntyped): |
--disallow-any-generics |
Bare list, dict, frozenset in an annotation |
--warn-redundant-casts |
cast(int, x) where x is already an int |
--warn-unused-ignores |
A # type: ignore that is no longer suppressing anything |
--warn-return-any |
returning an Any from a function that promises a concrete type |
--no-implicit-reexport |
Importing a.b.C through module a.d that merely imported it |
--strict-equality |
==, != or in between types that cannot overlap |
--extra-checks |
Partially-overlapping TypedDict updates; Concatenate-prepended arguments treated as positional-only |
Three of these are worth pausing on because they behave differently from the rest.
--check-untyped-defs is not a prohibition, it is an unmuting. By default, mypy walks into an unannotated function body and simply does not check it — not “checks it loosely”, does not check it. You can call a method that does not exist, add an int to a dict, and return a str from something that will be used as a Path, and mypy says nothing, because it has decided this function is not opted in. Flip this flag on a legacy codebase and the error count does not go up by ten percent, it goes up by an order of magnitude. That is not the flag being noisy. That is you finding out how much of your repo was never checked.
--strict-equality catches a bug class you have shipped. if user_id == request.headers.get("X-User") where one side is an int and the other a str | None is always False, forever, silently. Same for if status in ("ACTIVE", "PENDING") where status is an enum member rather than a string. There is no runtime error to find in a log. The comparison just quietly never fires, and the feature is quietly never enabled.
--extra-checks is the odd one out. It is itself a bundle, and mypy’s own documentation describes it as checks that are “technically correct but may be impractical in real code”. It is in --strict anyway, so it is part of the deal.
💡A colleague argues that --disallow-untyped-defs and --disallow-incomplete-defs are the same flag written twice. Are they?
click to reveal
No, and the difference shows up the moment you turn --disallow-untyped-defs off, which is exactly what you do when introducing mypy to an existing codebase.
--disallow-untyped-defs rejects a function with no annotations at all. --disallow-incomplete-defs rejects a function with some. On a greenfield project under full strict they look identical, because everything must be fully annotated either way and the first flag fires on everything the second would.
On a migration, they come apart. The standard sequence is: enable --disallow-incomplete-defs first, so that any function someone has started annotating must be finished — you are stopping the half-typed function, which is the genuinely dangerous artefact, because it looks checked and is not. Meanwhile the thousands of entirely untyped legacy functions stay legal and you burn them down module by module, flipping --disallow-untyped-defs on per-package as you go.
Two flags because they answer two different migration questions: “may new code be untyped?” and “may code be partly typed?”
The two this course is built on
Two of the thirteen do more work here than the other eleven combined.
--warn-unused-ignores is what makes a suppression expire. Without it, # type: ignore is permanent debt: the underlying problem gets fixed in a library upgrade, the comment stays, and it goes on silently suppressing whatever new error appears on that line three years later. With it, an ignore that has stopped suppressing something is itself an error, so the comment gets deleted the moment it stops earning its place. That is also the mechanism behind this course’s negative type assertions — a test that asserts “mypy rejects this code” is written as a deliberately-wrong line with a scoped ignore on it. If a future change makes the checker accept the line, the ignore becomes unused, and the build breaks. It is the only way to assert a type error rather than a value.
--no-implicit-reexport is what makes a module’s public surface real. By default, if app/db.py does from sqlalchemy import Session, then from app.db import Session type-checks — every import you make is silently part of your public API, and every downstream module can start depending on your import list. With the flag on, a name is only re-exported if you say so: list it in __all__, or write the redundant-looking from sqlalchemy import Session as Session. Now __all__ is not documentation, it is the enforced boundary, and deleting an internal import cannot break a consumer who was never supposed to reach through you for it.
Be precise about the limit, though: --no-implicit-reexport controls what escapes the module. It does not verify what is in __all__. Put a typo’d name in that list and mypy will not tell you — pyright will.
💡You add --no-implicit-reexport to a healthy 40k-line codebase and get 300 errors, all of them in test files and all of the form Module "app.models" has no attribute "User". User really is defined in app.models. What happened?
click to reveal
app/models.py is almost certainly a package __init__.py that gathers names from submodules:
# app/models/__init__.py
from app.models.user import User
from app.models.order import Order
That is a completely legitimate design — a package presenting a flat public surface — and --no-implicit-reexport breaks it, because from mypy’s point of view these are private imports that happen to live in an __init__.
There are two fixes and they are not equivalent.
The mechanical one is from app.models.user import User as User. It looks like a typo, it is in fact the typing spec’s designated redundant-alias form, and it says “this import is a re-export, on purpose”.
The better one is __all__ = ["Order", "User"]. It does the same job, it is one line rather than one edit per import, and it is a list — so it is reviewable, greppable, and diffable. When someone adds a symbol to it in a pull request, that is now a visible act of API expansion instead of an invisible side effect of adding an import.
Prefer __all__. And note the direction of causation: you did not have 300 errors, you had 300 accidental public symbols, and the flag is the first thing that has ever told you.
The two that are not in there
Both of these appear on published “mypy strict flags” lists. Neither is in the bundle.
--warn-unreachable is not in --strict. Dead code — a branch after a return, an else on an exhaustive if, a try body that cannot raise what you are catching — passes --strict untouched. That one hurts, because unreachable code is very often the fossil of a bug: the branch became unreachable when someone narrowed a type upstream, and the logic it contained silently stopped running. This course’s gate turns it on.
--warn-unused-configs is not in --strict. A [[tool.mypy.overrides]] section naming a module that no longer exists is silently ignored, so the per-module exemption you carefully added for legacy.parsers keeps sitting in your pyproject.toml long after legacy.parsers was deleted — and the next module someone renames inherits the same fate.
You may also wonder where --strict-optional went. It is not in the list because it has not needed to be for years: treating None as a distinct type rather than a member of every type is mypy’s default, and has been for long enough that turning it off is the unusual act.
What this course actually runs
Your submission is checked with --strict plus the two additions above, and one concession:
--strict
--warn-unreachable
--extra-checks
--ignore-missing-imports
--ignore-missing-imports is deliberate. You are being graded on the annotations you wrote, not on whether your machine happens to have type stubs installed for some third-party package — a missing stub produces an import-untyped error that has nothing to do with the exercise. In your own repo, do the opposite: leave it off, and add stubs or a narrowly-scoped per-module override, so that “we have no types for this dependency” stays visible instead of becoming ambient.
💡--strict is thirteen flags today. What is the maintenance hazard in writing strict = true in your pyproject.toml and never thinking about it again?
click to reveal
That the set is not frozen. mypy’s own documentation warns that the flags behind --strict may change between releases, and it has changed before. So strict = true is not a specification, it is a reference to a moving specification, resolved at the version of mypy your CI happened to install this morning.
The failure mode is not that a new check appears — that is fine, that is the point. It is that a new check appears in an unrelated pull request, so the person who has to deal with fifty new errors is whoever pushed next, at the worst possible moment, with no context on what changed. They will do the fastest thing that makes CI green, which is a blanket # type: ignore or a disable_error_code entry, and now the check is off permanently and nobody remembers why.
So: pin the checker. Put mypy==2.3.0 in your dev dependencies, exactly the way you pin the compiler in any other language. Upgrade it deliberately, in its own commit, with its own review, where the diff is “turn on nine new checks, fix the 50 things they found” and it can be reasoned about as the piece of work it actually is.
This applies with more force to the linter, where the rule count moves much faster than mypy’s flag list.
The one-line version
--strict is not “all the checks”. It is a named, versioned, thirteen-element set that omits dead-code detection and config validation, silently changes between releases, and — as the next article covers — leaves eight substantial holes wide open even when it passes clean.