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, 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"]
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.

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

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