cheat sheet
mypy
Catch type errors before runtime with mypy. Covers strict mode, common error codes, type: ignore annotations, gradual typing, and pyproject.toml configuration.
mypy — Static Type Checker
What it is
mypy is the reference static type checker for Python, created by Jukka Lehtosalo and maintained as part of the Python open-source ecosystem. It reads PEP 484 type annotations and reports type errors — mismatched argument types, missing return values, None-dereferences, incorrect signatures — without executing the program. It supports gradual typing so you can add annotations incrementally; use --strict mode to enforce full annotation coverage on new projects.
Install
pip install mypy
# Stubs for popular packages (only needed for untyped libraries)
pip install types-requests
Output: (none — exits 0 on success)
Quick example
# greet.py
def greet(name: str) -> str:
return f"Hello, {name}"
result: str = greet("Alice")
print(result)
# ❌ type error: greet expects str, not int
bad = greet(42)
mypy greet.py
Output:
greet.py:8: error: Argument 1 to "greet" has incompatible type "int"; expected "str" [arg-type]
Found 1 error in 1 file (checked 1 source file)
When / why to use it
- Catch bugs in function signatures before tests surface them.
- Self-documenting code: type hints describe intent better than docstrings.
- IDE completion: editors use type info for better autocomplete and inline errors.
- Gradually adoptable: add to one module at a time without converting the whole project.
Common pitfalls
Untyped third-party packages — mypy reports
Missing library stubs or py.typed markerfor libraries without type annotations. Either installtypes-<pkg>stubs or addignore_missing_imports = truein your config.
Optionalis not the same as a union withNone— in Python 3.10+ you can writestr | Nonedirectly. Before 3.10 you needOptional[str]fromtyping. mypy accepts both.
Start with
--ignore-missing-importsso untyped third-party packages don't block you. Tighten later with--strictonce you're ready.
Gradual typing
mypy is designed for incremental adoption. Start by annotating only the public API of critical modules:
# Step 1: check specific files
mypy my_module.py
# Step 2: check the whole package with lenient config
mypy src/ --ignore-missing-imports
# Step 3: strict mode (requires everything to be annotated)
mypy src/ --strict
Output: (none — exits 0 on success)
Richer example — Optional, generics, and Protocol
# typed_ops.py
from typing import Protocol, TypeVar
T = TypeVar("T")
class Comparable(Protocol):
def __lt__(self, other: "Comparable") -> bool: ...
def maximum(items: list[T], key=None) -> T:
if not items:
raise ValueError("empty list")
return max(items, key=key)
def find_first(items: list[T], predicate) -> T | None:
for item in items:
if predicate(item):
return item
return None
nums = [3, 1, 4, 1, 5, 9]
print(maximum(nums))
name = find_first(["Alice", "Bob", "Carol"], lambda s: s.startswith("B"))
print(name)
mypy typed_ops.py --strict
Output:
Success: no issues found in 1 source file
print(name.upper()) # ❌ name could be None
typed_ops.py:22: error: Item "None" of "str | None" has no attribute "upper" [union-attr]
Common error codes
| Code | Meaning |
|---|---|
[arg-type] | Argument type mismatch |
[return-value] | Wrong return type |
[union-attr] | Attribute access on a union that includes None |
[assignment] | Variable assigned the wrong type |
[no-untyped-def] | Function has no type annotations (strict mode) |
[import-untyped] | Imported module has no type stubs |
[override] | Overridden method is incompatible with base class |
Suppress a specific error
A # type: ignore[error-code] comment tells mypy to silence exactly one error on that line, leaving all other checks active. Always include the error code (e.g. [no-untyped-call]) rather than a bare # type: ignore so the suppression is self-documenting and won't silently mask future unrelated errors.
result = some_untyped_function() # type: ignore[no-untyped-call]
Use sparingly — prefer fixing the root cause.
pyproject.toml configuration
[tool.mypy]
python_version = "3.12"
strict = true
ignore_missing_imports = true
exclude = ["tests/", "migrations/"]
Checking an entire project
mypy src/ --show-error-codes --pretty
Output:
src/api.py:14: error: Function is missing a return type annotation [no-untyped-def]
src/utils.py:33: error: Argument 1 has incompatible type "str | None"; expected "str" [arg-type]
Found 2 errors in 2 files (checked 18 source files)
--strict mode and individual strictness flags
--strict is the headline mypy flag — it enables roughly a dozen individual strict checks at once. Each can be toggled independently if you want partial strictness (common when adopting on a legacy codebase). The list grows over time; new flags land in --strict after a release of being opt-in.
| Flag | What it forbids |
|---|---|
--disallow-untyped-defs | Function without complete annotations |
--disallow-incomplete-defs | Function with only some annotations |
--disallow-untyped-calls | Calling an untyped function from typed code |
--disallow-untyped-decorators | Decorators without annotations |
--check-untyped-defs | Type-check the body of untyped functions too |
--disallow-any-generics | list instead of list[int] |
--disallow-any-explicit | Explicit Any annotations |
--disallow-subclassing-any | Inherit from an Any-typed class |
--no-implicit-optional | def f(x: int = None) must be `x: int |
--strict-equality | 1 == "1" (always-false equality) |
--strict-concatenate | Concatenate enforced for callable params |
--warn-redundant-casts | cast(int, x) when x is already int |
--warn-unused-ignores | # type: ignore over a line that has no error |
--warn-return-any | Returning Any from a typed function |
--warn-unreachable | Code after return/raise, dead branches |
--no-implicit-reexport | from m import X doesn't re-export X without __all__ |
# All strict checks at once
mypy --strict src/
# Just the basics — useful for adoption
mypy --disallow-untyped-defs --warn-unused-ignores src/
# Strict everywhere except one legacy module
mypy --strict --no-warn-unused-ignores src/
Output:
src/legacy.py:14: error: Function is missing a return type annotation [no-untyped-def]
src/legacy.py:14: note: Use "-> None" if function does not return a value
Found 1 error in 1 file (checked 24 source files)
Adopt strictness gradually. Start with
disallow-untyped-defson a single module, then expand thefilespattern, then enable additional flags one at a time. Jumping straight to--stricton a 50k-line codebase produces hundreds of errors and no clear remediation path.
Third-party stubs — type information for untyped libraries
Many libraries ship without type annotations. mypy reports them as import-untyped and refuses to look inside their public APIs. The fix is to install separately-published stub packages — files named <package>-stubs/ or types-<name> that contain only signatures. The typeshed project (a community-maintained stub repository) provides stubs for hundreds of popular libraries.
# Install a known stub
pip install types-requests types-PyYAML types-redis
# Or have mypy install everything it suggests
mypy --install-types --non-interactive src/
Output:
Installing missing stub packages:
/usr/bin/python -m pip install types-requests types-PyYAML
Installation successful
The naming convention is:
- Inline (PEP 561) — library ships a
py.typedmarker file; mypy reads its.pyfiles directly. No install needed. Examples:pydantic,attrs,httpx. types-<name>package — separate distribution, sourced from typeshed. Examples:types-requests,types-PyYAML,types-redis.<name>-stubspackage — bespoke stubs maintained outside typeshed. Examples:pandas-stubs,boto3-stubs.
# Without stubs — mypy can't see signatures
import requests # error: Library stubs not installed for "requests"
resp = requests.get("https://example.com")
reveal_type(resp) # Any — useless
# With pip install types-requests:
import requests
resp = requests.get("https://example.com")
reveal_type(resp) # requests.models.Response — typed
For projects with many third-party imports, list the stubs in your dev dependencies:
[project.optional-dependencies]
dev = [
"mypy==1.11",
"types-requests",
"types-PyYAML",
"types-redis",
"pandas-stubs",
"boto3-stubs[s3,dynamodb]", # extras pick which submodules to stub
]
Generics with mypy
mypy understands every PEP 484+ generic construct — TypeVar, Generic, ParamSpec, Self, Concatenate, Protocol. See sections/python/typing for the full taxonomy of typing constructs; this section shows how mypy reports errors against them.
# generic_check.py
from typing import TypeVar, Generic
T = TypeVar("T")
class Box(Generic[T]):
def __init__(self, item: T) -> None:
self.item = item
def get(self) -> T:
return self.item
b: Box[int] = Box(42)
reveal_type(b.get()) # int
b.item = "wrong" # error: incompatible type "str"; expected "int"
mypy --strict generic_check.py
Output:
generic_check.py:14: note: Revealed type is "builtins.int"
generic_check.py:16: error: Incompatible types in assignment (expression has type "str", variable has type "int") [assignment]
Found 1 error in 1 file (checked 1 source file)
Protocols — structural subtyping
A Protocol describes what an object can do, not what it is. Any class with matching attributes and methods satisfies the protocol — no inheritance required. mypy validates protocol conformance at every call site.
# protocol_check.py
from typing import Protocol
class SupportsClose(Protocol):
def close(self) -> None: ...
def cleanup(resource: SupportsClose) -> None:
resource.close()
class File:
def close(self) -> None: ...
class NoClose:
pass
cleanup(File()) # ok
cleanup(NoClose()) # error: argument 1 has incompatible type "NoClose"
mypy --strict protocol_check.py
Output:
protocol_check.py:14: error: Argument 1 to "cleanup" has incompatible type "NoClose"; expected "SupportsClose" [arg-type]
protocol_check.py:14: note: "NoClose" is missing following "SupportsClose" protocol member:
protocol_check.py:14: note: close
Found 1 error in 1 file (checked 1 source file)
Plugins — extending the type system
mypy plugins teach the checker about idioms it doesn't understand natively — pydantic's runtime model construction, Django's ORM lazy fields, attrs' decorator-generated __init__. Plugins ship as separate Python packages and are enabled in config under plugins = [...].
| Plugin | Adds support for |
|---|---|
pydantic.mypy | pydantic model class semantics (BaseModel, Field, validators) |
mypy_django_plugin | Django ORM (QuerySet, related managers, settings) |
sqlalchemy.ext.mypy | SQLAlchemy 1.x ORM (deprecated; SQLAlchemy 2.x has native types) |
numpy.typing.mypy_plugin | NumPy shape and dtype awareness |
mypy_zope | Zope interfaces |
[tool.mypy]
plugins = [
"pydantic.mypy",
"mypy_django_plugin.main",
"numpy.typing.mypy_plugin",
]
[tool.pydantic-mypy]
init_forbid_extra = true
init_typed = true
warn_required_dynamic_aliases = true
[tool.django-stubs]
django_settings_module = "myapp.settings"
Without the pydantic plugin, mypy sees BaseModel subclasses as having __init__(*args, **kwargs) (Pydantic builds the constructor dynamically). With the plugin, mypy understands each field as a typed keyword argument and flags missing-required-field errors at the call site.
# Without plugin: pydantic models look like Any to mypy
from pydantic import BaseModel
class User(BaseModel):
id: int
name: str
User(id=1) # mypy: ok (because plugin not loaded)
User(id="oops") # mypy: ok (no error without plugin)
# With pydantic.mypy enabled:
User(id="oops") # error: Argument "id" has incompatible type "str"; expected "int"
User(name="Alice") # error: Missing named argument "id"
Gradual typing — adoption strategy
mypy is designed to start tolerant and tighten over time. The progression typically is:
- Add mypy to dev deps, run without arguments, accept the long error list as a baseline.
ignore_missing_imports = trueto silence third-party noise.- Type the most-called public functions first — your domain types and main API.
- Per-module strictness via
[[tool.mypy.overrides]]— strict onsrc/api/, lenient onsrc/legacy/. - Turn on
--strictglobally, walk down the remaining errors over weeks.
[tool.mypy]
python_version = "3.12"
strict = true
warn_unreachable = true
ignore_missing_imports = true
# Per-module relaxations during adoption
[[tool.mypy.overrides]]
module = ["myapp.legacy.*", "myapp.scripts.*"]
disallow_untyped_defs = false
check_untyped_defs = false
[[tool.mypy.overrides]]
module = ["third_party_untyped.*"]
ignore_missing_imports = true
Run mypy with --show-error-context and --pretty during adoption — the extra context makes triage faster:
mypy --show-error-codes --show-error-context --pretty src/
Output:
src/api/handler.py:14: error: Function is missing a type annotation [no-untyped-def]
def handle_request(request, user_id):
^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Found 1 error in 1 file (checked 24 source files)
Suppressing errors — # type: ignore with codes
A bare # type: ignore silences every error on the line, including future ones you'd want to know about. Always include the specific error code: # type: ignore[arg-type]. mypy's --warn-unused-ignores flag flags # type: ignore comments that no longer suppress anything — important for keeping the suppression list honest as you fix underlying bugs.
# Right — specific suppression
result = legacy_untyped() # type: ignore[no-untyped-call]
# Wrong — silently masks any future error on this line
result = legacy_untyped() # type: ignore
# A whole file:
# type: ignore[no-untyped-def,no-untyped-call]
# (at the top, before any imports)
# A whole import:
import legacy # type: ignore[import-untyped]
To turn off mypy for an entire file, put # mypy: ignore-errors at the top — mypy will parse but not type-check the file.
# legacy_module.py
# mypy: ignore-errors
# Reason: pre-typing era code, scheduled for rewrite in Q3
def messy_function(x):
return x + "anything"
Configuration: pyproject.toml and mypy.ini
mypy reads its config from one of (in order): mypy.ini, .mypy.ini, pyproject.toml ([tool.mypy]), setup.cfg ([mypy]). Use pyproject.toml for new projects; everything else exists for compatibility. Per-module overrides use a separate table with a module = "..." glob.
[tool.mypy]
python_version = "3.12"
mypy_path = "src"
packages = ["myapp", "myapp_tests"]
strict = true
warn_unreachable = true
warn_redundant_casts = true
warn_unused_ignores = true
disallow_any_generics = true
no_implicit_reexport = true
implicit_reexport = false
explicit_package_bases = true
namespace_packages = true
follow_imports = "normal"
ignore_missing_imports = false
exclude = ["build/", "dist/", "_drafts/", "migrations/"]
plugins = ["pydantic.mypy"]
# Cache directory
cache_dir = ".mypy_cache"
# Per-module relaxations
[[tool.mypy.overrides]]
module = "myapp.legacy.*"
disallow_untyped_defs = false
[[tool.mypy.overrides]]
module = ["redis.*", "boto3.*"]
ignore_missing_imports = true
[[tool.mypy.overrides]]
module = "tests.*"
disallow_untyped_defs = false
warn_return_any = false
| Key | Meaning |
|---|---|
python_version | The version of Python source code is written for (not the version mypy runs under) |
mypy_path | Like PYTHONPATH — directories to add when resolving imports |
files / packages | What to check by default when no path is passed |
follow_imports | normal (check) / silent (load for types, hide errors) / skip (treat as Any) / error |
namespace_packages | Allow PEP 420 namespace packages (folders without __init__.py) |
explicit_package_bases | Require explicit roots — important for src/ layouts |
cache_dir | Where to write incremental cache (default .mypy_cache) |
Incremental and daemon modes
mypy caches per-file type information in .mypy_cache/. Re-runs skip files whose source and dependency-graph haven't changed. For very large projects, the cache directory grows large; the daemon mode (dmypy) keeps the type graph in memory across runs for sub-second re-checks.
# Cold run, with caching to .mypy_cache/
mypy src/
# Re-run — picks up changes incrementally
mypy src/
# Daemon: start once, query many times
dmypy start
dmypy run -- src/
dmypy run -- src/myapp/api.py # checks just one file, instantly
dmypy stop
# Clear the cache if it gets corrupted
rm -rf .mypy_cache
Output:
Daemon started
Success: no issues found in 24 source files
Daemon stopped
For editor integrations and large CI pipelines, daemon mode is dramatic — a cold full-project check that takes 30 seconds becomes a 200 ms incremental check on the file you just edited.
mypy vs pyright vs pyre vs pytype
The Python type-checker landscape has four production-grade tools. They share the PEP 484+ spec but differ in inference strategy, ecosystem integration, and what they prioritise.
| Checker | Author | Strengths | Weaknesses |
|---|---|---|---|
| mypy | Python core (Jukka Lehtosalo et al.) | Reference implementation; widest plugin ecosystem; matches typeshed behaviour | Slower than competitors; less aggressive inference |
| pyright | Microsoft | Fastest (TypeScript-style incremental); powers VS Code Pylance; the most aggressive inference | Smaller plugin ecosystem; some divergence from typeshed; default behaviour stricter than mypy |
| pyre | Meta | Designed for monorepos; excellent at very large codebases | Less commonly used outside Meta; OCaml-based, harder to extend |
| pytype | Infers types for un-annotated code; produces stubs | Slower; less commonly recommended for new projects |
Practical guidance:
- Default to mypy for new projects — broadest community, most plugins, most documentation.
- Use pyright when speed matters or when you're already on VS Code (Pylance is pyright under the hood).
- Run both in CI for high-stakes code — each catches some errors the other misses, particularly around protocols and generics.
- Pyre/pytype are appropriate for the specific niches above; most projects don't need them.
# Both checkers on the same file produce slightly different output
mypy --strict src/api.py
pyright src/api.py
Output (pyright):
/home/alice/src/api.py
/home/alice/src/api.py:14:5 - error: Function is missing a return type annotation (reportGeneralTypeIssues)
1 error, 0 warnings, 0 informations
Common pitfalls
Optional[X]vsX | None— semantically identical; mypy treats both identically. Modernise toX | Noneon Python 3.10+ for readability.Anypropagates — once a value isAny, every attribute and operation on it is alsoAny. Use--warn-return-anyto flag functions that secretly returnAny(commonly from untyped third-party calls).# type: ignorewithout a code — silently masks any future error on the line. Enable--warn-unused-ignoresto catch the stale ones.- mypy can't see runtime-imported modules —
import importlib; m = importlib.import_module("x")produces aModuletyped astypes.ModuleType. UseTYPE_CHECKINGto import for types only. - Module discovery — without
[tool.mypy] packages = [...]or explicit paths,mypy src/misses files in folders without__init__.py. Setexplicit_package_bases = trueforsrc/layouts. - Cache invalidation across Python versions — mypy's cache is keyed by version. Switching from 3.11 to 3.12 invalidates everything. CI should cache by
pyproject.toml + python_versionhash. reveal_typeleft in code —reveal_type(x)is a checker directive but Python sees it as a call to a nonexistent name. CPython 3.11+ ignores it; older versions raiseNameError. Remove after debugging.IterablevsIteratorvsSequence— mismatches surface asarg-typeerrors.foracceptsIterable;[0]requiresSequence. Pick the loosest viable type for parameters; the tightest for return values.- Generic class methods — methods on generic classes inherit the class's type variables; redeclaring
Tinside a method creates a new variable that won't unify with the outer one. mypy --install-typesis interactive by default — pair with--non-interactivein CI or it hangs waiting for confirmation.- Implicit Optional removed in modern mypy —
def f(x: int = None)used to be inferred asint | None. Modern mypy errors; add| Noneexplicitly. - Plugin version drift —
pydantic.mypyis tied to specific pydantic versions. Pin both:pydantic==2.6, mypy==1.11so the plugin matches the runtime.
Real-world recipes
Bootstrap mypy on a new project
Three commands: install, write a strict-but-tolerant config, run. Errors that emerge from third-party libraries are silenced with ignore_missing_imports; new code is held to a strict standard.
cd ~/code/myproject
pip install mypy
cat >> pyproject.toml <<'EOF'
[tool.mypy]
python_version = "3.12"
strict = true
ignore_missing_imports = true
warn_unreachable = true
warn_redundant_casts = true
plugins = ["pydantic.mypy"]
[[tool.mypy.overrides]]
module = "tests.*"
disallow_untyped_defs = false
EOF
mypy src/
Output:
Success: no issues found in 12 source files
Adopt mypy on a legacy codebase
The first mypy src/ on an untyped codebase produces hundreds of errors. Adopt one module at a time using per-module overrides.
[tool.mypy]
python_version = "3.12"
ignore_missing_imports = true
# Strict for the new modules
[[tool.mypy.overrides]]
module = ["myapp.api.*", "myapp.models.*"]
strict = true
disallow_untyped_defs = true
# Lenient for everything else (for now)
[[tool.mypy.overrides]]
module = ["myapp.legacy.*", "myapp.scripts.*"]
ignore_errors = true
As you migrate modules off the lenient list, move them under the strict list. Once everything is strict, drop the per-module overrides and set top-level strict = true.
CI integration with cached results
mypy's .mypy_cache/ is large but cacheable. Persist it between CI runs for an order-of-magnitude speedup. Pin both mypy and stub packages.
# .github/workflows/typecheck.yml
name: typecheck
on: [push, pull_request]
jobs:
mypy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: "3.12", cache: pip }
- run: pip install mypy==1.11 types-requests types-PyYAML -r requirements.txt
- uses: actions/cache@v4
with:
path: .mypy_cache
key: mypy-${{ runner.os }}-${{ hashFiles('pyproject.toml', 'requirements.txt') }}
restore-keys: mypy-${{ runner.os }}-
- run: mypy --show-error-codes --pretty src/
Pre-commit integration
mypy needs the project's third-party packages to type-check imports correctly. The pre-commit-mypy mirror runs in an isolated venv — list the needed packages under additional_dependencies.
# .pre-commit-config.yaml
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.11.0
hooks:
- id: mypy
files: ^src/
additional_dependencies:
- "pydantic>=2"
- "types-requests"
- "types-PyYAML"
- "types-redis"
args: ["--strict", "--ignore-missing-imports"]
stages: [pre-push] # mypy is slow; run on push, not every commit
See sections/python/pre-commit for the full setup.
Type a third-party library yourself
When a library has no stubs and isn't in typeshed, create a local stub package. Files go in a stubs/ directory pointed to by mypy_path in your config.
[tool.mypy]
mypy_path = "stubs"
# stubs/some_untyped_lib/__init__.pyi
from typing import Any
class Client:
def __init__(self, host: str, port: int = 8080) -> None: ...
def fetch(self, key: str) -> dict[str, Any]: ...
def close(self) -> None: ...
mypy now reads these stubs for some_untyped_lib; the actual library remains untyped at runtime.
Type-check just the changed files
Linting the whole repo on every push is wasteful once you have a green baseline. Use git diff to feed mypy only changed files plus their dependencies (--follow-imports=silent so mypy still resolves them).
#!/usr/bin/env bash
# scripts/mypy-changed.sh
changed=$(git diff --name-only --diff-filter=ACMR origin/main...HEAD -- '*.py')
[ -z "$changed" ] && { echo "no Python changes"; exit 0; }
mypy --follow-imports=silent $changed
Output: (none — exits 0 on success)
bash scripts/mypy-changed.sh
Output:
Success: no issues found in 3 source files
Run mypy in daemon mode for editor integration
dmypy keeps the type graph in memory. Editors that integrate it (Neovim's null-ls, the official mypy VS Code extension) see sub-second feedback. Start the daemon once at the project root; queries thereafter are nearly free.
# Start the daemon
dmypy start --
# Query the full project
dmypy run -- src/
# Query a single file (sub-second)
dmypy run -- src/myapp/api.py
# Check status
dmypy status
# Restart after big config changes
dmypy restart --
# Stop
dmypy stop
Output:
Daemon started
Success: no issues found in 24 source files
Success: no issues found in 1 source file
Cross-check with pyright
Run pyright alongside mypy on critical PRs — each catches errors the other misses. Pyright's stricter default mode often surfaces issues mypy needs --strict for.
- run: pip install mypy==1.11 pyright
- run: mypy src/
- run: pyright src/
Type-check Jupyter notebooks
nbqa runs any Python tool against .ipynb cells. For type-checking notebooks (which mypy can't read natively), nbqa mypy extracts cells, runs mypy, and reports inline.
pip install nbqa
nbqa mypy notebooks/
Output:
notebooks/eda.ipynb:cell_3:14: error: Function is missing a return type annotation [no-untyped-def]
Found 1 error in 1 file (checked 1 source file)
See also
sections/python/typing— full taxonomy of PEP 484+ type constructs (Protocol, ParamSpec, Self, TypeGuard, etc.).sections/python/pydantic— runtime validation; pairs with mypy via thepydantic.mypyplugin.sections/python/pre-commit— wire mypy into git so it runs before pushes.sections/python/pyproject-toml— the file where[tool.mypy]lives.sections/python/ruff— companion lint tool; ruff doesn't type-check, so mypy stays in the toolchain.