Static Type Checking with Mypy
Why type checking
Section titled “Why type checking”Type hints help you:
- catch bugs before runtime
- improve editor autocomplete
- document function contracts
Minimal example
Section titled “Minimal example”from typing import Iterable
def total(values: Iterable[int]) -> int:
return sum(values)Running mypy
Section titled “Running mypy”mypy your_packageExample config (pyproject.toml)
Section titled “Example config (pyproject.toml)”[tool.mypy]
python_version = "3.11"
ignore_missing_imports = true
warn_unused_ignores = true
warn_redundant_casts = true
strict_optional = trueStart with non-strict, then tighten gradually.
One file, six tools
Section titled “One file, six tools”Every measurement on this page — and on the other five tools in this phase — comes from running the tool against this deliberately flawed file:
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)| tool | what it reported on sample.py |
|---|---|
| flake8 | 5 style and dead-code issues. No security findings. |
| pylint | 4 issues, score 8.10/10 |
| mypy | 1 type error, which neither linter saw |
| bandit | 5 security issues, 3 of them HIGH |
| radon | complexity 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.
The error nothing else found
Section titled “The error nothing else found”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.
flowchart LR
A["def add(a: int, b: int) -> int"] --> M["mypy builds a type for every expression"]
C["add('1', 2)"] --> M
M --> E["str is not int -> arg-type error"]
M --> N["nothing runs; this is STATIC"]
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.
Annotations do nothing at runtime
Section titled “Annotations do nothing at runtime”def add(a: int, b: int) -> int:
return a + b
add("1", "2") # '12' — Python does not check, and does not careAnnotations 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.
Adopting it on code that already exists
Section titled “Adopting it on code that already exists”Turning mypy on strict against a large untyped codebase produces thousands of errors and gets switched off the same afternoon. Start permissive and tighten:
[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 = trueThe pattern that works is per-module strictness: pick the code that matters most, make it strict, and grow the boundary.
| setting | effect |
|---|---|
ignore_missing_imports | stop errors from third-party packages without stubs |
disallow_untyped_defs | require annotations on every function |
warn_unused_ignores | flag # type: ignore comments that no longer apply |
strict | all 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.
What mypy cannot tell you
Section titled “What mypy cannot tell you”- 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_defsboundary matters.
Check yourself
Section titled “Check yourself”-
mypy reported an incompatible argument type on line 29. What did flake8, pylint and bandit report about that line?
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.
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.
-
What happens at runtime when you call add('1', '2') on a function annotated a: int, b: int?
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.
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.
-
What is the workable way to adopt mypy on a large untyped codebase?
Strict-everywhere produces thousands of errors and gets switched off the same day. Growing a strict boundary keeps the signal actionable.
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.coffeeCtapch.feedbackHeading
pch.feedbackSubheading