Skip to content

Static Type Checking with Mypy

Type hints help you:

  • catch bugs before runtime
  • improve editor autocomplete
  • document function contracts
types_example.py
from typing import Iterable
 
 
def total(values: Iterable[int]) -> int:
    return sum(values)
bash
mypy your_package
toml
[tool.mypy]
python_version = "3.11"
ignore_missing_imports = true
warn_unused_ignores = true
warn_redundant_casts = true
strict_optional = true

Start with non-strict, then tighten gradually.

Every measurement on this page — and on the other five tools in this phase — comes from running the tool against this deliberately flawed file:

sample.py
import os
import subprocess
import hashlib
 
 
def process(items, user_input, flag = False):
    unused_var = 42
    result=[]
    for i in items:
        if i > 0:
            if flag:
                if i % 2 == 0:
                    result.append(i*2)
                else:
                    result.append(i)
            else:
                result.append(i)
    password = "hunter2"
    h = hashlib.md5(password.encode()).hexdigest()
    os.system("echo " + user_input)
    subprocess.call("ls " + user_input, shell=True)
    return result
 
 
def add(a: int, b: int) -> int:
    return a + b
 
 
x = add("1", 2)
toolwhat it reported on sample.py
flake85 style and dead-code issues. No security findings.
pylint4 issues, score 8.10/10
mypy1 type error, which neither linter saw
bandit5 security issues, 3 of them HIGH
radoncomplexity A (5), maintainability A (56.30)

The headline is that no tool subsumes another. flake8 read the whole file and reported nothing about the shell injection on line 20. bandit read the same file and said nothing about the type error on line 29. Running one and concluding the code is clean is the mistake this phase exists to prevent.

sketch Six tools, one file, almost no overlap p5.js
Each tool was run against the same 29-line file. Click a tool to see what it found and, more importantly, what it did not.
mypy sample.py
sample.py:29: error: Argument 1 to "add" has incompatible type "str"; expected "int"  [arg-type]
Found 1 error in 1 file (checked 1 source file)

That is a genuine bug — add("1", 2) returns "12" if the annotation is a lie, or raises if it is not — and flake8, pylint and bandit all read the same line and said nothing. Type checking is a different question from style, and the answer is only available to a tool that follows the types.

diagram Diagram mermaid

Nothing was executed to find it. That is the point: the bug is reported for a line that may only run in an unusual branch, months from now.

runtime.py
def add(a: int, b: int) -> int:
    return a + b
 
add("1", "2")        # '12'  — Python does not check, and does not care

Annotations are metadata. The checker is a separate program you have to run — which is why type errors reach production in codebases that annotate but never run mypy.

Turning mypy on strict against a large untyped codebase produces thousands of errors and gets switched off the same afternoon. Start permissive and tighten:

pyproject.toml
[tool.mypy]
python_version = "3.13"
warn_unused_ignores = true
warn_return_any = true
 
# start here, then remove these as modules get typed
ignore_missing_imports = true
 
[[tool.mypy.overrides]]
module = "myapp.core.*"        # one package at a time
disallow_untyped_defs = true
strict = true

The pattern that works is per-module strictness: pick the code that matters most, make it strict, and grow the boundary.

settingeffect
ignore_missing_importsstop errors from third-party packages without stubs
disallow_untyped_defsrequire annotations on every function
warn_unused_ignoresflag # type: ignore comments that no longer apply
strictall of the strict flags at once

warn_unused_ignores is the one people forget. Without it, # type: ignore comments accumulate long after the errors they silenced were fixed.

  • Nothing about style — that is flake8’s job.
  • Nothing about security — bandit flagged three HIGH issues in this file that mypy read straight past.
  • Nothing about values: divide(1, 0) type-checks perfectly.
  • Nothing about untyped code — an unannotated function is largely invisible, which is why a growing disallow_untyped_defs boundary matters.
pch.quizTag pch.quizDefaultTitle
  1. mypy reported an incompatible argument type on line 29. What did flake8, pylint and bandit report about that line?

    pch.quizShowAnswer

    B — nothing; none of them follow types — Type checking is a different question. Only a tool that builds a type for every expression can see that a str was passed where an int was declared.

  2. What happens at runtime when you call add('1', '2') on a function annotated a: int, b: int?

    pch.quizShowAnswer

    B — it returns '12'; annotations are metadata and Python does not enforce them — The checker is a separate program you have to run. That is why type errors reach production in codebases that annotate but never run mypy.

  3. What is the workable way to adopt mypy on a large untyped codebase?

    pch.quizShowAnswer

    B — start permissive with ignore_missing_imports, then make one module strict at a time using per-module overrides — Strict-everywhere produces thousands of errors and gets switched off the same day. Growing a strict boundary keeps the signal actionable.

pch.coffeeTagline

pch.coffeeCta

pch.feedbackHeading

pch.feedbackSubheading