Skip to content
← Back to explainers

Python explainer

Turn On mypy Checks Beyond --strict

About 4 min to read. Allow extra time to work through examples.

mypy --strict leaves some useful checks off. Turn on checks for names that might never get a value, discarded tasks, unmarked overrides and unreachable code, and upgrade mypy in its own pull request.

This article uses Python. You can make it your practice mode to browse related content across the site.

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

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.

step 38 of 41

Modules, Packaging and Tooling

Up next Choose Which Checks Block a Merge