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.

pre-commit mypy scope versus CI mypy scope The pre-commit hook sees only staged files inside an isolated virtualenv, while the CI job runs mypy over the entire tree in the full project environment. pre-commit hook isolated venv, staged files only order.py ✓ api.py ✓ unstaged callers not analyzed third-party types absent by default CI: mypy . full env, whole tree every module installed deps cross-file errors caught source of truth for the gate
The hook trades completeness for speed; CI restores completeness. Run both, and let CI be authoritative.

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

What lives inside the isolated mypy hook virtualenv The sealed hook venv contains mypy and each additional_dependencies package, so a listed import is fully typed while an unlisted one collapses to Any. hook virtualenv (sealed) mypy + mypy_extensions + typing_extensions additional_dependencies: pydantic — py.typed (inline) types-requests — stub-only sqlalchemy — py.typed (inline) nothing else is importable listed import → fully typed calls and attributes checked unlisted import → [import-untyped] value becomes Any, checks stop
Only mypy and the packages you name in additional_dependencies live in the hook venv; every other import degrades to Any.

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.

Dependents are not checked, dependencies are An import graph shows the staged module checked along with its imported dependencies, while the caller that imports it sits above, unanalyzed, holding the real error. mypy rates.py — what gets analyzed checkout.py (caller) NOT staged → NOT checked imports (dependent, unseen) rates.py (staged) checked — signature changed decimal (dep) typing (dep) imports (dependencies, followed)
mypy follows a staged file's imports downward to its dependencies but never upward to its callers, so a signature change slips past the hook and only CI's whole-tree run catches it.
# 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.

Runtime vs static analysis Neither the hook nor the CI job changes what your code does at runtime — both only read annotations and report. The divergence is purely about analysis scope: the same source passes one and fails the other because they feed mypy different file sets and different installed packages, not because the code behaves differently.

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.

Three checkpoints of increasing scope and authority Editor checks one file, pre-commit checks the staged set, and CI checks the whole tree in the real environment and is the merge gate. feedback loop widens, authority increases editor / LSP one open file instant, advisory pre-commit hook staged files, sealed venv sub-second, blocks commit CI: mypy . whole tree, real env matrix of Python versions merge gate — authoritative
Each checkpoint sees a wider slice of the program than the last; only CI runs the whole tree in the real environment, so only CI is the gate.
# .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 matrixmypy --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.

Mistake and fix checklist Four common mypy pre-commit mistakes each paired with the single configuration change that fixes them. mistake fix no additional_dependencies list every typed/stub package trusting staged-files scope pass_filenames: false + mypy . in CI rev vs pyproject version drift pin one mypy version in both hand-maintained stale stub pins sync pins with the lockfile
Read the table as a pre-commit audit: each amber mistake has one blue configuration fix that restores parity with CI.
  • No additional_dependencies. The hook can’t import your deps, emits [import-untyped] / [import-not-found], and quietly degrades imported values to Any, 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’s mypy . is what catches cross-file breakage.
  • Version drift between rev: and pyproject.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_dependencies must move when pyproject.toml moves, or the hook checks against stale stubs.
  • Running mypy --install-types inside 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 in additional_dependencies instead.

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.

Back to Pre-commit Hooks Setup