Skip to content
← All articles

The Eight Things --strict Does Not Catch

Passing the gate is not the same as being well typed. Eight verified holes in mypy --strict — explicit Any, laundered Any, dead code, missing @override, cast, blanket ignores, deprecated spellings, mutable defaults — and the config that closes them.

There is a specific kind of confidence that comes from a green type check, and it is mostly unearned. mypy --strict proves that your annotations are internally consistent. It says nothing about whether they are informative. A module can pass strict with no errors while asserting, in effect, nothing at all.

Below are eight holes, each of which I have watched pass a strict check in production code. None of them requires a # type: ignore to get through. All eight are demonstrable in five lines.

1. Explicit Any is completely legal

from typing import Any

def process(payload: Any) -> Any:
    return payload.whatever(1, 2, 3).nonexistent_field

Clean under --strict. Every one of the thirteen flags is about missing annotations; Any is a present annotation, and Any is the type that is compatible with everything in both directions. You have annotated a function into complete meaninglessness and the gate applauds.

This is the single biggest hole, because it is also the easiest escape hatch for someone under deadline pressure. --disallow-any-explicit closes it. It is not in --strict, and it is aggressive enough that most teams display it rather than gate on it — but you should at least know whether your Any count is going up or down.

2. Any laundered through an untyped boundary

Worse than the last one, because there is no Any visible anywhere:

import json

def load_name(raw: str) -> str:
    data = json.loads(raw)
    name: str = data["user"]["name"]
    return name

Clean under --strict. json.loads returns Any, indexing Any gives Any, and assigning Any to a str-annotated variable is allowed silently. Put reveal_type(name) on the next line and mypy answers builtins.str — with total confidence, on the basis of nothing. If the payload has an integer there, name is an int, every downstream .strip() blows up at runtime, and the checker that “proved” this was a str was never wrong, because it never checked.

Every boundary in your system does this: json.loads, yaml.safe_load, os.environ.get on a dict you built untyped, a database driver without stubs, an untyped third-party client. Any does not stay where you put it. It flows.

💡If x: str = json.loads(raw) cannot be trusted, what should the boundary look like instead? click to reveal

The rule is: Any must be converted to a real type by a check that runs, not by an annotation that does not.

Three escalating options.

The cheapest is to type the boundary as what it actually is — object, not Any. data: object = json.loads(raw) forces every subsequent access through an explicit narrowing (isinstance(data, dict), then isinstance(value, str)), because object has no __getitem__. Verbose, and mypy will now catch every unchecked step. That verbosity is the shape of the validation you were skipping.

The middle option is a TypedDict plus a hand-written validator that actually inspects the payload and raises on mismatch. The TypedDict describes the shape; the validator is what makes the description true. A TypedDict alone does nothing at runtime — it is an annotation, and cast(UserPayload, json.loads(raw)) is exactly the lie from hole 5.

The one you want in production is a parsing library — pydantic, msgspec, cattrs — where a single call both validates and returns something whose static type is guaranteed by a runtime check. That is the whole value proposition: it moves the guarantee from “someone wrote an annotation” to “a check ran and it passed”.

The mechanical version of the rule: search your codebase for functions that return Any from the stdlib and third-party stubs, and make sure that for each one, a validation exists between it and the first place its result is treated as a specific type. That list is short and it is where your production incidents live.

3. Unreachable code

def render(value: str) -> str:
    if value is None:
        return "<none>"
    return value.upper()

Clean under --strict. Add --warn-unreachable and you get error: Statement is unreachable [unreachable] on line 3.

Dead code is worth catching not because it wastes bytes but because it is a fossil. That parameter used to be str | None. Somebody tightened it six months ago — correctly! — and the branch that handled the other case silently stopped running. There is no error in the log. That feature just stopped happening.

One honest caveat, because it decides how much you should trust the flag: mypy’s unreachability analysis is type-based, not value-based. It knows a str can never be None, and it knows an int can never also be a str, so it catches the fossil above. It has no idea that elif n <= 0 after if n > 0 is exhaustive, and will not flag the return that follows. --warn-unreachable finds branches killed by narrowing, which is the interesting class, and nothing else.

4. A missing @override

class Base:
    def handle(self, event: str) -> None: ...

class Child(Base):
    def handel(self, event: str) -> None:   # typo
        print(event)

Clean under --strict. Child.handel is simply a new method; Base.handle is still inherited and still does nothing. Every call site type-checks. The feature does nothing, forever.

Note carefully what fixes this, because it is not what people expect. Writing @override (3.12+, typing.override) on handel does catch it — Method "handel" is marked as an override, but no base method was found with this name [misc]. But --enable-error-code explicit-override, the flag everyone reaches for, does not catch this example: it fires on methods that genuinely do override without being marked, and handel overrides nothing. The two work as a pair. explicit-override enforces the discipline of always decorating; the decorator is what actually detects the typo. Turn on the error code so that the decorator is not optional, and the typo has nowhere to hide.

5. cast() is not checked at all

from typing import cast

def get_user(raw: object) -> str:
    return cast(str, raw)

Clean under --strict, and raw can be a socket. cast is not a conversion and not an assertion — it emits no code at runtime and performs no check at compile time. It is a directive to the checker meaning “stop reasoning here, I have decided”.

That is occasionally the right call, at exactly the kind of boundary where you have a runtime guarantee the type system cannot express. It is far more often a way to make an error message go away. This course bans cast by default and allowlists it per problem, for that reason.

Note the asymmetry with --warn-redundant-casts, which is in the thirteen: mypy will tell you when a cast was unnecessary. It will never tell you when a cast was wrong.

6. A blanket # type: ignore

result = library.compute(config)  # type: ignore

This suppresses every error on that line — the arg-type error you triaged, and also the attr-defined error that appears next year when compute is renamed, and the assignment error when its return type changes. --warn-unused-ignores will nag you when the comment suppresses nothing, which is a real and useful check, but it is perfectly happy for one comment to be quietly suppressing three things.

The fix costs six characters: # type: ignore[arg-type]. Now the suppression is scoped to the one diagnostic you actually looked at, and any new error on that line surfaces. Turn on --enable-error-code ignore-without-code and the unscoped form becomes an error in its own right — which is the single highest-value error code not in --strict.

7. Deprecated typing spellings

from typing import Dict, List, Optional

def index(items: List[str]) -> Dict[str, Optional[int]]: ...

Clean under --strict. And — this surprises people — still clean with --enable-error-code deprecated turned on. Modernisation to list[str], dict[str, int | None] is entirely ruff’s job (the UP rules); mypy has no opinion.

Worth knowing the actual status of the old spellings, because both of the loud positions are wrong. They are formally deprecated. Their removal is not currently planned — so the migration is about consistency and readability, not about an impending break.

8. Mutable default arguments

def add_tag(tag: str, tags: list[str] = []) -> list[str]:
    tags.append(tag)
    return tags

Clean under --strict. Fully annotated, entirely correct types, and the default list is created once at function-definition time and shared by every caller who does not pass one. Two requests, and the second sees the first one’s tags.

This is a ruff catch (B006, from bugbear), not a mypy catch. It is a good illustration of why “we run a type checker” and “we run a linter” are not substitutes for each other: they detect disjoint bug classes, and the classic Python footgun lives entirely on the linter’s side of the line.

💡Of these eight, which two would you close first on a codebase you have just inherited, and why those two? click to reveal

Number 6 (blanket ignores) and number 2 (laundered Any), in that order — and the reasoning is about information, not severity.

--enable-error-code ignore-without-code is first because it is nearly free and it is a prerequisite for everything else. On an inherited codebase the blanket ignores are a fog: you cannot tell which of them are load-bearing, which are stale, and which are hiding three errors each. Forcing them to be scoped costs an afternoon of mechanical work, produces a diff a reviewer can actually read, and — the real payoff — turns your suppression list into an inventory. Now grep -c 'type: ignore\[' by error code tells you where the type debt actually is, and --warn-unused-ignores can start expiring the dead ones automatically.

Laundered Any is second because it is the hole that produces production incidents rather than merely bad code. The other six make your code worse. This one makes your type check false: it hands you a green build that asserts a payload field is a str when nothing has ever verified it. Fixing it is not a config change, it is design work at each boundary, which is why it goes second rather than first — but it is the one where a week of effort buys you a measurable drop in 500s.

Deliberately not first: mutable defaults and deprecated spellings. Both are real, both are one ruff invocation with --fix, and neither will teach you anything about the codebase. Do them in the same commit as turning ruff on and forget about them.

The strict-plus config

Everything above closes with configuration you can paste today. This is the Bronze gate for this course.

[tool.mypy]
python_version = "3.12"
strict = true
warn_unreachable = true
warn_unused_configs = true
enable_error_code = ["ignore-without-code", "redundant-self"]

[tool.ruff.lint]
select = ["F","E4","E7","E9","W","B","UP","SIM","RUF","I","C4","PIE","T20","ICN"]

And the Silver additions, once that is green — the error codes that make a reviewer’s job easier: redundant-expr, possibly-undefined, truthy-bool, truthy-iterable, unused-awaitable, explicit-override, mutable-override, exhaustive-match, deprecated.

Two of those deserve a note. explicit-override is what closes hole 4. exhaustive-match is what makes a match statement over a closed union fail to compile when someone adds a variant — which turns “add a case to every match in the codebase” from a code-review responsibility into a compiler responsibility, and is one of the highest-leverage checks in the entire list.

The honest summary

Passing mypy --strict means: everything is annotated, and the annotations do not contradict each other. It does not mean the annotations are true, that every branch runs, that your overrides override, that your casts are sound, or that your suppressions are scoped. It is a floor. Treat a green strict check as the beginning of the review, not the end of it.