Skip to Content
Adding mypy to an Existing Python Project Without 500 Errors on Day One
Advanced
0 likes
Tutorials

Adding mypy to an Existing Python Project Without 500 Errors on Day One

Start mypy with its defaults, fix what it finds, turn on check_untyped_defs, then make one module at a time strict. Every function you annotate checks every place that calls it, so start with the busiest ones.

Don't turn on strict mypy for a whole existing project on day one. You'll get hundreds of errors, most of them "missing type annotation", and the few real bugs get lost in the noise. Start with mypy's defaults, fix what it finds, then make one file at a time stricter. Each step is small enough to finish in one sitting.

Skip ahead: If mypy already runs clean with defaults on your project, go to "Step 3: strict, one module at a time".

Why the defaults are quiet on purpose

mypy skips the inside of any function that has no type hints at all. That sounds like a weakness. For an existing project it's the feature that makes adoption possible: your old code is left alone, and only code you've started typing gets checked.

Here's a small project with a real bug in it:

def apply_discount(price, percent):
    return round(price * (100 - percent) / 100, 2)


def parse_price(text: str) -> float:
    return float(text.replace(",", ""))
from shop.pricing import apply_discount, parse_price


def line_total(qty, price_text):
    price = parse_price(price_text)
    return apply_discount(price, "10") * qty


def summary(totals: list[float]) -> str:
    return "Total: " + sum(totals)

Plus an empty shop/__init__.py, so shop is a package.

Step 1: run it with defaults

python -m pip install mypy
mypy shop
shop/report.py:10: error: Unsupported operand types for + ("str" and "float")  [operator]
shop/report.py:10: error: Unsupported operand types for + ("str" and "Literal[0]")  [operator]
shop/report.py:10: note: Right operand is of type "float | Literal[0]"
Found 2 errors in 1 file (checked 3 source files)

Two errors on the same line, and it's one real bug: "Total: " + sum(totals) adds text and a number. (mypy reports it twice because sum() of an empty list is the integer 0, so it checks both cases.) That line would crash the first time summary() runs. mypy found it because summary has type hints.

Notice what it didn't report. apply_discount(price, "10") passes a string as the percent, which also crashes. But line_total has no hints, so mypy didn't look inside it. That's the trade-off of the defaults: quiet, but blind in untyped code.

Fix the real error before doing anything else:

from shop.pricing import apply_discount, parse_price


def line_total(qty, price_text):
    price = parse_price(price_text)
    return apply_discount(price, "10") * qty


def summary(totals: list[float]) -> str:
    return f"Total: {sum(totals)}"
Success: no issues found in 3 source files

Step 2: put the settings in pyproject.toml

Add a [tool.mypy] section, so everyone runs the same check with plain mypy:

[tool.mypy]
files = ["shop"]
python_version = "3.13"
check_untyped_defs = true

check_untyped_defs = true is the best first step up. It checks the inside of untyped functions too, using what it can figure out, without asking you to add any hints:

Success: no issues found in 3 source files

Still clean. mypy now looks inside line_total, and it knows price is a float, because parse_price has hints. But it still can't see the "10" bug: apply_discount has no hints, so mypy has no idea what it expects. Give it hints:

def apply_discount(price: float, percent: float) -> float:
    return round(price * (100 - percent) / 100, 2)


def parse_price(text: str) -> float:
    return float(text.replace(",", ""))
shop/report.py:6: error: Argument 2 to "apply_discount" has incompatible type "str"; expected "float"  [arg-type]
Found 1 error in 1 file (checked 3 source files)

There it is. Before the hints, mypy couldn't check that call at all, so there was nothing to report. That's the general pattern: every function you annotate also checks every place that calls it. Annotate the functions that are called the most first. You get the most checking for the least typing.

Fix the call to apply_discount(price, 10) and move on.

from shop.pricing import apply_discount, parse_price


def line_total(qty, price_text):
    price = parse_price(price_text)
    return apply_discount(price, 10) * qty


def summary(totals: list[float]) -> str:
    return f"Total: {sum(totals)}"
Success: no issues found in 3 source files

⚠ Warning: If turning on check_untyped_defs gives you hundreds of errors, don't fix them all in one commit, and don't silence them with a blanket # type: ignore on every line. Turn it on for one package at a time with a per-module section (below), and keep the rest on defaults until you get to them.

Step 3: strict, one module at a time

Once a module is fully annotated, lock it in. Per-module sections only apply to the modules you name:

[tool.mypy]
files = ["shop"]
python_version = "3.13"
check_untyped_defs = true

[[tool.mypy.overrides]]
module = ["shop.pricing"]
disallow_untyped_defs = true
disallow_incomplete_defs = true
Success: no issues found in 3 source files

shop.pricing must now have hints on every function, so nobody can add an untyped one there by accident. shop.report is still on the softer settings, because line_total has no hints yet. Next week, annotate report.py, add it to the list, and so on. The list grows; it never shrinks.

Third-party packages without types

You'll see errors like Skipping analyzing "somepackage": module is installed, but missing library stubs or py.typed marker. First check if stubs exist: mypy often suggests the exact pip install types-... command. If there are none, ignore that one package, not all of them:

[[tool.mypy.overrides]]
module = ["somepackage.*"]
ignore_missing_imports = true

Don't put ignore_missing_imports = true in the main [tool.mypy] section. It hides missing imports for every package, including typos in your own module names.

# type: ignore, with a reason

Sometimes mypy is wrong, or the fix isn't worth it today. Ignore one specific error code on one line, and say why:

value = legacy_api()  # type: ignore[no-untyped-call]  # vendor code, no hints until v3

Add warn_unused_ignores = true to the settings. When someone fixes the cause, mypy tells you the ignore isn't needed anymore, and it doesn't pile up.

Run it automatically

A check that people have to remember to run gets forgotten. Add mypy to your CI or a pre-commit hook, so a new error stops the change instead of reaching production. Same command, every time: plain mypy, reading pyproject.toml.

Common mistakes

  • **--strict on everything, day one.** Hundreds of errors, then mypy gets turned off.
  • **Global ignore_missing_imports.** Hides real mistakes.
  • **Bare # type: ignore everywhere.** Use the error code and a reason.
  • Annotating rarely-used functions first. Start with the ones everything else calls.

What happens if you skip mypy

Both bugs in this example were found without running the code. Without mypy, the first one shows up the first time someone asks for a summary, and the second one the first time someone calculates a line total. In a small script that's quick to find. In a big project, it's whoever happens to run that path first.

Which function in your project is called from the most places, and does it have type hints yet?

About

Running mypy with defaults, pyproject.toml settings, check_untyped_defs, per-module strict overrides, missing stubs and type: ignore with codes. After this, you can add type checking to old code without stopping all other work.

Responsible
Christopher R
Last Update
09/20/2026
Reading Time
6 min
Was this helpful?
​ 0 ​ 0

Comments (0)

Sign in to comment

No comments yet.