Running mypy in pre-commit vs CI
TL;DR — The mirrors-mypy pre-commit hook runs mypy inside an isolated virtualenv and passes it only the files staged in your commit. That makes it fast, but it means the hook can’t see your third-party types or cross-file errors unless you add additional_dependencies. Treat the hook as quick local feedback and run a full mypy . in CI as the authoritative gate — the two are not interchangeable.
Developers reach for the pre-commit mypy hook expecting it to be a local mirror of CI. It isn’t, and the mismatch produces the most confusing failure mode in Python type checking: green locally, red in CI, on the exact same commit. Understanding why comes down to two design choices in the hook — an isolated environment and a changed-files argument list.
The isolated environment
pre-commit installs each hook in its own managed virtualenv, deliberately sealed off from your project’s installed packages. That isolation is what makes hooks reproducible across machines, but it means mypy inside the hook cannot import your dependencies. When mypy can’t find a package’s types, it reports [import-not-found] or, for an installed-but-untyped package, [import-untyped] — or, worse, silently treats the import as Any and stops checking the values that flow from it.
The fix is additional_dependencies: a list of packages installed into the hook’s venv alongside mypy. Include your typed runtime dependencies and any types-* stub packages.
# .pre-commit-config.yaml — pre-commit, mypy 1.13
repos:
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.13.0
hooks:
- id: mypy
additional_dependencies:
- pydantic==2.9.2 # brings its own inline types
- types-requests==2.32.0.20240914
- sqlalchemy==2.0.35
args: ["--strict"]
Every stub or typed package your code imports must appear here, and each pin must track pyproject.toml. This list is the maintenance tax of running mypy in pre-commit — forget a package and the hook checks less than it appears to. For what --strict bundles, see mypy configuration & strictness.
The changed-files pitfall
pre-commit passes each hook only the files staged in the current commit. For a linter that reasons file-by-file, that is correct and fast. mypy is not that kind of tool: it builds a whole-program import graph, and an error’s cause and its report site often live in different files.
# src/billing/rates.py — mypy 1.13
def apply_discount(cents: int, pct: float) -> int:
return round(cents * (1 - pct))
# src/billing/checkout.py — the caller (NOT staged this commit)
from billing.rates import apply_discount
total = apply_discount("1999", 0.1) # [arg-type]: str not int
If you edit rates.py and change the signature, but checkout.py isn’t in this commit, the hook never analyzes the caller and the [arg-type] error is invisible. CI’s mypy . checks the whole tree and catches it. This is the single most common “passed pre-commit, failed CI” scenario.
You can force the hook to check everything with pass_filenames: false and an explicit target, which trades the speed benefit for correctness:
# .pre-commit-config.yaml — check the whole package, not just staged files
- id: mypy
pass_filenames: false
args: ["--strict", "src/"]
additional_dependencies: ["types-requests==2.32.0.20240914"]
CI as the source of truth
Keep the hook for the sub-second feedback loop while you type, but make CI authoritative. The CI job installs the real project environment and runs the whole tree, so its result is the one that gates merges.
# .github/workflows/typecheck.yml — GitHub Actions, mypy 1.13
- run: pip install -e ".[dev]" # the actual dependency set
- name: Full mypy (source of truth)
run: mypy --strict src/ tests/ # whole tree, real env
Pin the same mypy version in rev: and in pyproject.toml so the two agree on inference. When they disagree despite matching versions, it is almost always the changed-files scope or a missing additional_dependencies entry.
Common mistakes
- No
additional_dependencies. The hook can’t import your deps, emits[import-untyped]/[import-not-found], and quietly degrades imported values toAny, so real[attr-defined]errors never surface locally. - Trusting the hook as a full check. Because it only sees staged files, a caller in an unstaged module hides a
[arg-type]regression. CI’smypy .is what catches cross-file breakage. - Version drift between
rev:andpyproject.toml. A newer mypy in one place emits[unreachable]or tightened[assignment]diagnostics the other doesn’t, reproducing the green-local/red-CI split for a different reason. - Duplicating the dependency list by hand and letting it rot. Every pin in
additional_dependenciesmust move whenpyproject.tomlmoves, or the hook checks against stale stubs.
FAQ
Should I drop pre-commit mypy and rely only on CI?
No — the local hook catches obvious mistakes before they cost a CI round-trip. Keep it, but understand it is a fast approximation. Let the full mypy . in CI be the gate that actually blocks merges.
Why does the hook pass but CI fails on the same commit?
Almost always one of two reasons: the hook only analyzed the staged files and missed a caller in another module, or the hook’s isolated venv lacked a package that CI has installed. Add additional_dependencies and consider pass_filenames: false to close the gap.