This function passes mypy --strict:
def last_positive(values: list[int]) -> int:
for value in values:
if value > 0:
found = value
return found
Calling last_positive([]) raises UnboundLocalError, because found was
never assigned. mypy has a check for this mistake, but --strict
leaves it off. --strict turns on a fixed list of flags, and mypy has more
checks outside that list.
See what --strict turns on
Run python -m mypy --help and find --strict. In mypy 2.4, its list
includes the checks for missing annotations, for unused # type: ignore
comments and for returning Any from a typed function. It does not include
--warn-unreachable or --warn-unused-configs. The list can change between
releases, so check the version your project uses with
python -m mypy --version. The
command-line reference
describes the current list.
Add checks you can explain
Here is a starting configuration for a Python 3.14 project, checked with mypy 2.4.0:
[tool.mypy]
python_version = "3.14"
strict = true
warn_unreachable = true
warn_unused_configs = true
enable_error_code = [
"ignore-without-code",
"possibly-undefined",
"unused-awaitable",
"explicit-override",
]
| Check | What it finds |
|---|---|
ignore-without-code |
A bare # type: ignore, which could hide any error on its line |
possibly-undefined |
A name assigned only on some paths, such as found above, which is assigned inside an if in a loop |
unused-awaitable |
An awaitable result, such as a Task, that the code throws away |
explicit-override |
A method that overrides a base class method without the @override decorator |
warn_unreachable reports code that mypy's type analysis says can never
run. warn_unused_configs reports a [[tool.mypy.overrides]] section whose
module pattern matches no file, which usually means a typo. When you change
the configuration, run mypy from scratch so its cache cannot hide anything:
python -m mypy --no-incremental --config-file pyproject.toml myapp
Await every coroutine and keep every task
import asyncio
async def save() -> None:
pass
async def forget_coroutine() -> None:
save()
async def discard_task() -> None:
asyncio.create_task(save())
mypy reports the first mistake by default, as unused-coroutine. Calling save()
without await creates a coroutine and never runs it. The second mistake is
different. create_task does start save(), but the code throws away the
task, so nothing can wait for it or see it fail. unused-awaitable reports
that one.
If the work must finish before the caller goes on, write await save(). If
it should run alongside other work, give the task an owner, such as a
TaskGroup. Writing _ = asyncio.create_task(save()) silences mypy and
fixes nothing.
Mark intentional overrides
from typing import override
class Formatter:
def render(self, value: int) -> str:
return str(value)
class HexFormatter(Formatter):
@override
def render(self, value: int) -> str:
return hex(value)
assert HexFormatter().render(15) == "0xf"
@override says that a method is meant to replace one in a base class. If
someone renames Formatter.render, mypy reports that HexFormatter.render
no longer overrides anything. explicit-override goes one step further, and
reports every override that lacks the decorator. It skips __init__ and
__new__. Neither check tests what the method does. See the
optional error codes
for every check's exact rules.
Switch checks on one package at a time
Other checks are worth trying too. exhaustive-match reports a match
statement that misses a case. truthy-bool and redundant-expr report
conditions that are always true or always false. If you turn those two on
for the whole project, an override can keep them off in a legacy package
while you fix it:
[[tool.mypy.overrides]]
module = ["myapp.legacy.*"]
disable_error_code = ["truthy-bool", "redundant-expr"]
Write down why the exception exists. Check that the pattern matches the
module names mypy uses for your files. warn_unused_configs tells you when
it matches none.
Check your understanding
After you turn on warn_unreachable, mypy flags a branch that runs in production. What should you check?
Show answer
Hide answer
Compare the declared types with the values that reach that branch. A
wrong return annotation, an outdated stub package or an unchecked Any can
make mypy's picture differ from what happens at runtime. An earlier return
or isinstance check can also explain the warning. Fix the annotation or
the code where you can. If mypy is wrong in one place, add a coded ignore
such as # type: ignore[unreachable] and a comment that says why. Keep a
test that runs the branch.
Upgrade mypy in its own pull request
Pin mypy and your stub packages, for example in a dependency group with a committed lockfile, and make CI install those versions. A new mypy release can report new errors in code that did not change. Read the release notes, and upgrade in a pull request with no other changes. Then each new error comes from the upgrade, and you can judge each one on its own.