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

bash
pip install mypy
# Stubs for popular packages (only needed for untyped libraries)
pip install types-requests

Output: (none — exits 0 on success)

Quick example

python
# 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)
bash
mypy greet.py

Output:

text
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 marker for libraries without type annotations. Either install types-<pkg> stubs or add ignore_missing_imports = true in your config.

Optional is not the same as a union with None — in Python 3.10+ you can write str | None directly. Before 3.10 you need Optional[str] from typing. mypy accepts both.

Start with --ignore-missing-imports so untyped third-party packages don't block you. Tighten later with --strict once you're ready.

Gradual typing

mypy is designed for incremental adoption. Start by annotating only the public API of critical modules:

bash
# 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

python
# 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)
bash
mypy typed_ops.py --strict

Output:

text
Success: no issues found in 1 source file
python
print(name.upper())  # ❌ name could be None
text
typed_ops.py:22: error: Item "None" of "str | None" has no attribute "upper"  [union-attr]

Common error codes

CodeMeaning
[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.

python
result = some_untyped_function()  # type: ignore[no-untyped-call]

Use sparingly — prefer fixing the root cause.

pyproject.toml configuration

toml
[tool.mypy]
python_version = "3.12"
strict = true
ignore_missing_imports = true
exclude = ["tests/", "migrations/"]

Checking an entire project

bash
mypy src/ --show-error-codes --pretty

Output:

text
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.

FlagWhat it forbids
--disallow-untyped-defsFunction without complete annotations
--disallow-incomplete-defsFunction with only some annotations
--disallow-untyped-callsCalling an untyped function from typed code
--disallow-untyped-decoratorsDecorators without annotations
--check-untyped-defsType-check the body of untyped functions too
--disallow-any-genericslist instead of list[int]
--disallow-any-explicitExplicit Any annotations
--disallow-subclassing-anyInherit from an Any-typed class
--no-implicit-optionaldef f(x: int = None) must be `x: int
--strict-equality1 == "1" (always-false equality)
--strict-concatenateConcatenate enforced for callable params
--warn-redundant-castscast(int, x) when x is already int
--warn-unused-ignores# type: ignore over a line that has no error
--warn-return-anyReturning Any from a typed function
--warn-unreachableCode after return/raise, dead branches
--no-implicit-reexportfrom m import X doesn't re-export X without __all__
bash
# 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:

text
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-defs on a single module, then expand the files pattern, then enable additional flags one at a time. Jumping straight to --strict on 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.

bash
# 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:

text
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.typed marker file; mypy reads its .py files directly. No install needed. Examples: pydantic, attrs, httpx.
  • types-<name> package — separate distribution, sourced from typeshed. Examples: types-requests, types-PyYAML, types-redis.
  • <name>-stubs package — bespoke stubs maintained outside typeshed. Examples: pandas-stubs, boto3-stubs.
python
# 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:

toml
[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.

python
# 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"
bash
mypy --strict generic_check.py

Output:

text
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.

python
# 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"
bash
mypy --strict protocol_check.py

Output:

text
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 = [...].

PluginAdds support for
pydantic.mypypydantic model class semantics (BaseModel, Field, validators)
mypy_django_pluginDjango ORM (QuerySet, related managers, settings)
sqlalchemy.ext.mypySQLAlchemy 1.x ORM (deprecated; SQLAlchemy 2.x has native types)
numpy.typing.mypy_pluginNumPy shape and dtype awareness
mypy_zopeZope interfaces
toml
[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.

python
# 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:

  1. Add mypy to dev deps, run without arguments, accept the long error list as a baseline.
  2. ignore_missing_imports = true to silence third-party noise.
  3. Type the most-called public functions first — your domain types and main API.
  4. Per-module strictness via [[tool.mypy.overrides]] — strict on src/api/, lenient on src/legacy/.
  5. Turn on --strict globally, walk down the remaining errors over weeks.
toml
[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:

bash
mypy --show-error-codes --show-error-context --pretty src/

Output:

text
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.

python
# 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.

python
# 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.

toml
[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
KeyMeaning
python_versionThe version of Python source code is written for (not the version mypy runs under)
mypy_pathLike PYTHONPATH — directories to add when resolving imports
files / packagesWhat to check by default when no path is passed
follow_importsnormal (check) / silent (load for types, hide errors) / skip (treat as Any) / error
namespace_packagesAllow PEP 420 namespace packages (folders without __init__.py)
explicit_package_basesRequire explicit roots — important for src/ layouts
cache_dirWhere 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.

bash
# 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:

text
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.

CheckerAuthorStrengthsWeaknesses
mypyPython core (Jukka Lehtosalo et al.)Reference implementation; widest plugin ecosystem; matches typeshed behaviourSlower than competitors; less aggressive inference
pyrightMicrosoftFastest (TypeScript-style incremental); powers VS Code Pylance; the most aggressive inferenceSmaller plugin ecosystem; some divergence from typeshed; default behaviour stricter than mypy
pyreMetaDesigned for monorepos; excellent at very large codebasesLess commonly used outside Meta; OCaml-based, harder to extend
pytypeGoogleInfers types for un-annotated code; produces stubsSlower; 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.
bash
# Both checkers on the same file produce slightly different output
mypy --strict src/api.py
pyright src/api.py

Output (pyright):

text
/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

  1. Optional[X] vs X | None — semantically identical; mypy treats both identically. Modernise to X | None on Python 3.10+ for readability.
  2. Any propagates — once a value is Any, every attribute and operation on it is also Any. Use --warn-return-any to flag functions that secretly return Any (commonly from untyped third-party calls).
  3. # type: ignore without a code — silently masks any future error on the line. Enable --warn-unused-ignores to catch the stale ones.
  4. mypy can't see runtime-imported modulesimport importlib; m = importlib.import_module("x") produces a Module typed as types.ModuleType. Use TYPE_CHECKING to import for types only.
  5. Module discovery — without [tool.mypy] packages = [...] or explicit paths, mypy src/ misses files in folders without __init__.py. Set explicit_package_bases = true for src/ layouts.
  6. 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_version hash.
  7. reveal_type left in codereveal_type(x) is a checker directive but Python sees it as a call to a nonexistent name. CPython 3.11+ ignores it; older versions raise NameError. Remove after debugging.
  8. Iterable vs Iterator vs Sequence — mismatches surface as arg-type errors. for accepts Iterable; [0] requires Sequence. Pick the loosest viable type for parameters; the tightest for return values.
  9. Generic class methods — methods on generic classes inherit the class's type variables; redeclaring T inside a method creates a new variable that won't unify with the outer one.
  10. mypy --install-types is interactive by default — pair with --non-interactive in CI or it hangs waiting for confirmation.
  11. Implicit Optional removed in modern mypydef f(x: int = None) used to be inferred as int | None. Modern mypy errors; add | None explicitly.
  12. Plugin version driftpydantic.mypy is tied to specific pydantic versions. Pin both: pydantic==2.6, mypy==1.11 so 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.

bash
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:

text
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.

toml
[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.

yaml
# .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.

yaml
# .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.

toml
[tool.mypy]
mypy_path = "stubs"
python
# 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).

bash
#!/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
bash scripts/mypy-changed.sh

Output:

text
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.

bash
# 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:

text
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.

yaml
- 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.

bash
pip install nbqa
nbqa mypy notebooks/

Output:

text
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 the pydantic.mypy plugin.
  • 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.