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 under ~/.cache/pre-commit/, keyed by the hook repository, its pinned rev, and the exact additional_dependencies list. That cache key matters: change a single pin and pre-commit tears the environment down and rebuilds it on the next run, which is why the first commit after editing the config is slow while every subsequent one is fast. The environment is deliberately sealed off from your project’s own virtualenv — it holds mypy, its runtime deps (mypy_extensions, typing_extensions), and nothing else unless you say so. That isolation is exactly what makes hooks reproducible across machines and CI, but it means mypy inside the hook cannot import your dependencies.
mirrors-mypy pins mypy to the tag in rev, so rev: v1.13.0 installs mypy 1.13.0 exactly. Inside that sealed venv mypy resolves imports through the PEP 561 lookup order: a package’s own inline types (signalled by a py.typed marker file), then an installed stub package (types-requests, pandas-stubs), then the stubs bundled in typeshed. Your application code and its installed dependencies are not on that path, so an unqualified import pydantic finds nothing to work with.
When the lookup fails, mypy reports one of two distinct error codes. [import-untyped] means the module was located but ships no type information — importable, but no py.typed and no stub package. [import-not-found] means mypy could not locate the module at all. Both surface only when ignore_missing_imports is false, which --strict guarantees; otherwise mypy silently treats the import as Any and stops checking every value that flows from it, so a genuine [attr-defined] typo further down the file never fires.
# app/client.py — mypy in the sealed hook venv, no additional_dependencies
import requests # error: Library stubs not installed for "requests" [import-untyped]
import pydantic # error: Cannot find implementation or library stub for module [import-not-found]
resp = requests.get("https://api.example.com")
resp.jsonn() # NOT reported: requests is Any here, so the typo stays invisible
The fix is additional_dependencies: packages installed into the hook’s venv alongside mypy. Whether you list the runtime package or a stub package depends on how the library ships its types. pydantic and sqlalchemy carry py.typed and expose types from the real package, so you install the runtime distribution (and, for pydantic, unlock its mypy plugin via plugins = ["pydantic.mypy"]). requests is untyped, so you install the stub-only types-requests — that pulls in the .pyi files but not requests itself, which is fine because mypy reads stubs and never imports the library at check time.
# .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 should 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. Two version-sensitive details are worth nailing down. Features backported through typing_extensions — Self before 3.11, override before 3.12, TypeAlias before 3.10 — are always available in the hook because the venv installs typing_extensions, so code targeting an older interpreter still type-checks. And from __future__ import annotations (PEP 563) turns annotations into strings that mypy still evaluates statically; it changes nothing about isolation, only about runtime introspection. Pyright sidesteps the sealed-venv problem entirely: it is normally pointed at your real interpreter through venvPath/pythonVersion in pyrightconfig.json and resolves imports from that environment’s site-packages, so the “hook can’t see my deps” gotcha is specific to the mypy pre-commit hook. For what --strict actually 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.
The precise mechanic is easy to misremember. When you invoke mypy rates.py, mypy checks rates.py and the modules it imports (its dependencies), following those imports according to --follow-imports. What it does not do is check the modules that import rates.py — its dependents. Editing a function signature is exactly the case where the breakage lives in a dependent, so passing only the staged rates.py can never surface it. This is the asymmetry that makes the changed-files model unsafe for a whole-program checker.
# 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"]
Two --follow-imports modes soften the trade-off without abandoning the changed-files model. With the default --follow-imports=normal, mypy type-checks the imported modules it can reach; --follow-imports=silent still reads them for inference but suppresses their errors (useful when a dependency is not yet clean); --follow-imports=skip treats them as Any; and --follow-imports=error fails loudly on any followed import. None of these makes mypy check dependents, so they narrow the “missed imported types” gap but not the “missed caller” gap — only a whole-target run closes the latter. What does make the pass_filenames: false variant tolerable in a hook is mypy’s incremental cache: the first whole-src/ run populates .mypy_cache/, and subsequent runs re-check only the modules whose fingerprints changed, so a project of a few thousand modules re-validates in well under a second once warm. Add require_serial: true alongside pass_filenames: false so pre-commit does not shard the file list across parallel workers, which would defeat the single whole-program invocation mypy needs.
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. Think of the three checkpoints as a widening funnel: the editor’s language server flags one file as you type, the pre-commit hook validates the staged set on commit, and CI re-checks the entire tree in the true dependency environment before merge.
# .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. Three CI-specific settings make the gate trustworthy. Run a version matrix — mypy --python-version 3.9 through 3.13 — because a dict[str, int] builtin-generic annotation or a match statement that checks clean on 3.12 can raise [syntax] or [misc] under an older --python-version, and CI is where that must be caught. Avoid --install-types in CI: it tries to pip install missing stubs interactively and can hang or reach the network mid-run, so declare every stub explicitly instead. And rely on mypy’s exit code — non-zero on any error — rather than grepping output, keeping continue-on-error unset so a type failure actually blocks the merge; add --junit-xml report.xml when you want the failures rendered as annotations. Cache .mypy_cache/ in CI keyed on the OS, Python version, and a hash of the lockfile, since a cache written by a different interpreter is silently discarded and rebuilt. See GitHub Actions type checking for the full workflow.
Common mistakes
Every one of these mistakes produces the same misleading signal — a green hook — while leaving a real defect for CI or production to find. Pairing each with its concrete fix turns “it passed locally” from a false reassurance into a checklist you can audit against your .pre-commit-config.yaml.
- 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. - Running
mypy --install-typesinside the hook. It prompts for confirmation and hits the network to fetch stubs, which stalls the sealed, offline hook environment; declare the stub packages explicitly inadditional_dependenciesinstead.
The unifying lesson is that the pre-commit hook and the CI job answer two different questions: the hook asks “does this staged change look locally consistent?” and CI asks “is the whole program type-correct in the real environment?” Keeping both, and being explicit about which one gates a merge, is what turns type checking from an occasional surprise into a reliable guardrail. When a discrepancy appears, reproduce the CI environment locally with a plain mypy --strict src/ tests/ in your project venv before touching the hook config — that one command settles almost every “green locally, red in CI” report.
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.