Automating Pre-Commit Type Validation for Python Projects

TL;DR

Pin your type checker version in .pre-commit-config.yaml via additional_dependencies, set pass_filenames: false for mypy and pyright, and use a files: regex to scope hooks to your source directory. Mirror the same config in CI by running pre-commit run --all-files with a cached .mypy_cache keyed on your lockfile hash.

Shift type validation to the commit stage to enforce consistency without blocking developer velocity. This guide provides exact hook syntax, environment isolation strategies, and CI parity configurations to automate type checking with minimal overhead. Isolate checker dependencies and apply incremental strictness to maintain velocity while guaranteeing type safety.

pre-commit hook environment isolation The project virtual environment contains unpinned or mixed packages. pre-commit creates separate, pinned virtual environments for each hook, ensuring deterministic type checker versions regardless of what is installed project-wide. Project venv (shared packages) mypy 1.2 (old) ruff 0.3 (old) requests 2.28 ⚠ version drift risk isolates mypy hook venv mypy==1.17.0 (pinned) ruff hook venv ruff==0.14.0 (pinned) Deterministic results every commit
pre-commit creates a separate pinned virtual environment per hook, isolating each tool from project-level package versions.

Hook Configuration & Environment Isolation

Pre-commit creates a dedicated virtual environment for each registered hook, keyed on the hook’s repo, rev, and additional_dependencies, and caches it under ~/.cache/pre-commit. That isolation is the whole point: it decouples the type checker your commits are validated against from whatever happens to be in the project’s .venv. Relying on the project environment instead — via language: system or a local hook that shells out to a globally installed mypy — reintroduces version drift and produces ModuleNotFoundError when a developer’s site-packages differs from a teammate’s. Pinning the checker and its stubs with additional_dependencies makes the analysis reproducible on any machine, including a fresh CI runner.

The community mirror https://github.com/pre-commit/mirrors-mypy exists precisely because mypy is distributed as a wheel with compiled components; the mirror publishes a tagged rev per mypy release so the hook environment installs a known build. Pyright ships through RobertCraigie/pyright-python, which wraps the Node-based pyright binary and downloads it on first run. A local hook (language: system or language: python with entry: mypy) is the escape hatch when you must run the checker against the project environment — for example when the code imports first-party packages installed in editable mode — but you then own the reproducibility problem yourself.

additional_dependencies is where third-party stubs live. A package that ships inline types advertises them with a py.typed marker under PEP 561; libraries that don’t (older requests, PyYAML, python-dateutil) publish a separate types-<name> distribution on PyPI that you must install into the hook environment for mypy to see. Because the hook venv is isolated, these stubs are invisible unless listed explicitly — this is the single most common cause of “works locally, fails in the hook” reports. Pin them to a compatible range; a stub package released after a library’s API changes can itself introduce new errors.

For comprehensive type-graph resolution, set pass_filenames: false. Type checkers are whole-program analyzers: mypy and pyright build a module import graph and resolve types across file boundaries. When pass_filenames is true (the default), pre-commit invokes the hook with only the staged files as arguments, so mypy checks payments.py without ever loading the models.py it imports — cross-module inference collapses and you get spurious [import], [attr-defined], and [name-defined] errors or, worse, false negatives where an unchecked dependency silently degrades to Any. Setting pass_filenames: false and scoping with files: lets the checker discover the full graph itself. Align your local hook architecture with the broader Static Analysis Tools & CI Integration pipeline so the same graph is analyzed everywhere.

pass_filenames true versus false With pass_filenames true the hook passes only the staged file and cross-module imports resolve to errors; with pass_filenames false the checker discovers and types the full import graph. pass_filenames: true pass_filenames: false payments.py (staged) models.py db.py imports not loaded [attr-defined] false errors or silent Any fallback payments.py models.py db.py full graph typed ✓
With pass_filenames: true the checker sees only the staged file and its imports collapse; pass_filenames: false lets it resolve the entire module graph.
repos:
  - repo: https://github.com/pre-commit/mirrors-mypy
    rev: v1.17.0
    hooks:
      - id: mypy
        name: mypy (strict)
        args: [--strict, --ignore-missing-imports, --show-error-codes]
        additional_dependencies: [types-requests, types-PyYAML]
        files: ^src/.*\.py$
        pass_filenames: false
  - repo: https://github.com/RobertCraigie/pyright-python
    rev: v1.1.403
    hooks:
      - id: pyright
        additional_dependencies: [pyright==1.1.403]
        files: ^src/.*\.py$
        pass_filenames: false

Two details make or break this config. First, rev must be a real git tag on the mirror — v1.17.0, not 1.17.0 — and additional_dependencies pins the mypy build the hook installs inside its own venv, independent of the rev on mirrors-mypy. Second, the files: regex is anchored and matched against repo-relative POSIX paths, so ^src/.*\.py$ scopes the hook to your package and skips tests/, docs/, and generated code. Run pre-commit run mypy --all-files once after any change to confirm the hook still sees the files you expect.

Validate the configuration against the typing syntax your python_version targets. Self (PEP 673) and TypeAlias (PEP 613) are 3.11+; on 3.8–3.10 import them from typing_extensions, which must then appear in additional_dependencies so the isolated hook environment can resolve them. Setting python_version = "3.11" in the mypy config tells the checker which standard-library stubs and syntax to assume regardless of the interpreter pre-commit itself runs on.

# src/models.py — targets Python 3.11+ (Self, TypeAlias from typing)
from typing import TypeAlias, Self
from dataclasses import dataclass

Payload: TypeAlias = dict[str, float]

@dataclass(slots=True)
class Transformer:
    def apply(self, data: Payload) -> Self:
        return self

On 3.8–3.10 the same module must read from typing_extensions import Self, TypeAlias, and mypy will still validate it correctly as long as typing_extensions is pinned in the hook. The strictness rollout guide covers which flags the --strict bundle turns on so you can decide how aggressive this hook should be.

Incremental Strictness & Legacy Code Migration

Enforcing strict typing across a legacy codebase in one commit floods the hook with hundreds of [no-untyped-def] and [no-any-return] errors and blocks every merge, which in practice trains developers to reach for git commit --no-verify. The durable approach is to keep the global config strict for code you want to hold to a high bar and carve out the technical debt with targeted overrides, so the hook stays green while you annotate the legacy tree package by package — the same ratchet described in rolling out disallow_untyped_defs incrementally.

Routing source paths to strict or exempt The type checker config routes application packages to strict checking and legacy modules to an ignore_errors exemption while keeping them importable. config routes each path to a strictness level src/app/* src/api/* legacy_module/* mypy config glob → flags strict = true errors fail hook ignore_errors imported, not judged
One config routes each path to a strictness level: application packages fail the hook on any error; legacy modules stay importable but exempt.

There are two layers of scoping and they do different jobs. The hook’s files: / exclude: regex controls which paths pre-commit hands to the checker (or, with pass_filenames: false, which changes trigger a run); the checker’s own config ([[tool.mypy.overrides]], pyright’s exclude) controls how strictly each module is judged once the graph is loaded. You need both: files: keeps the hook from firing on docs and notebooks, while overrides lets legacy_module stay imported (so first-party code that depends on it still type-checks) yet exempt from errors via ignore_errors = true. Reaching for files: alone to hide legacy code breaks inference for everything that imports it.

mypy and pyright express the exemption differently. mypy matches override blocks by module glob and, when two blocks match, the last one wins — so keep the most specific globs last or a broad lax block silently undoes a strict one. Pyright has no per-module strictness in a single config; you either exclude a path from checking entirely or set a directory’s typeCheckingMode. Ruff, meanwhile, handles linting and formatting, not deep type inference — keep it in a separate hook so a style violation never masquerades as a type error, and vice versa.

[tool.mypy]
python_version = "3.11"
strict = true

[[tool.mypy.overrides]]
module = "legacy_module.*"
ignore_errors = true          # imported, but its errors don't fail the hook
ignore_missing_imports = true

[tool.pyright]
typeCheckingMode = "strict"
include = ["src"]
exclude = ["src/legacy_module"]   # pyright: excluded from checking entirely

A pragmatic intermediate rung, when ignore_errors feels too permissive, is disallow_incomplete_defs = true without disallow_untyped_defs: it accepts fully-bare functions but flags half-annotated ones, so a package can be migrated signature by signature. If you want the hook to enforce annotations only on new code, remember that both mypy and pyright analyze files, not diffs — the honest approximation is to enforce strict flags on the packages where new code lands and let the override list in pyproject.toml serve as a self-documenting progress bar. Keep the override globs narrow: module = "app.*" with strict = true re-creates the flag-day problem you were avoiding.

Parallel Execution & Cache Optimization for Large Repos

mypy is incremental by default: its first run builds a full .mypy_cache of per-module fine-grained dependency data, and every run after that re-parses and re-checks only the modules whose source (or whose dependencies’ interfaces) changed, reading the rest straight from cache. In a local pre-commit workflow that cache survives between commits, so the first commit of the day is slow and the rest are near-instant. The two situations that throw the cache away are branch switches — git checkout rewrites source files and mypy re-checks whatever differs — and fresh CI checkouts, which start with no cache at all and pay the full cold-start cost on every run unless you restore it. Point cache_dir at a location outside the working tree so switching branches doesn’t interact with the cache, and in CI restore .mypy_cache from a keyed store.

Cold cache full check versus warm cache incremental check A cold first run with an empty cache does a full type check shown as a long bar, while a subsequent warm run reads unchanged modules from the cache and re-checks only the changed ones, shown as a short bar. Cache turns a full check into an incremental one Cold run empty cache re-parse + check every module Warm run cache restored changed read from .mypy_cache (skipped) only the changed slice is re-checked on a warm run
A cold run with an empty cache re-checks everything; a warm run restores .mypy_cache and only the changed modules are re-analyzed.

The cache key is where CI setups go wrong. mypy’s on-disk cache is versioned by the mypy release and the target python_version, and an incompatible cache is discarded silently — so a mypy upgrade quietly forces a full cold rebuild with no error to tell you why CI got slow. Key the restore on everything that invalidates types: the lockfile (which pins mypy and your stub packages) plus the mypy config, and add restore-keys for a partial-hit fallback that reuses a slightly stale cache rather than starting from nothing. The dmypy daemon (dmypy run -- src/) keeps the analysis in memory for sub-second re-checks, but a daemon is a poor fit for hooks: pre-commit spawns a fresh process per invocation, so the daemon’s speedup only helps if you keep it warm yourself in a dev loop. Most hook setups rely on the on-disk incremental cache instead, which needs no daemon lifecycle management.

Scoping is the other lever. The hook’s exclude: regex keeps pre-commit from handing heavy or generated paths to the checker at all — .venv, .tox, build/, migrations, and generated *_pb2.py protobuf modules are the usual suspects — while mypy’s own exclude in pyproject.toml covers paths discovered through imports when pass_filenames: false. For genuinely large graphs, mypy --jobs N parallelizes the check across processes and helps most when the module graph is wide (many independent packages) rather than deep. pyright is Node-based and already parallel, so it is usually fast without tuning; its --stats flag reports per-phase timings when you need to find a bottleneck.

- name: Cache mypy
  uses: actions/cache@v4
  with:
    path: .mypy_cache
    # key on the lockfile (pins mypy + stubs) and the config, not just pyproject
    key: ${{ runner.os }}-mypy-${{ hashFiles('uv.lock', 'pyproject.toml') }}
    restore-keys: ${{ runner.os }}-mypy-

- name: Run pre-commit
  run: pre-commit run --all-files --show-diff-on-failure

CI Parity & Exit Code Standardization

Local and CI must reach the same verdict on the same commit, and the most reliable way to guarantee that is to run the same hook definitions in both places rather than re-implementing the check. Invoke pre-commit run --all-files in CI instead of calling mypy directly: pre-commit then applies the identical rev, additional_dependencies, args, files, and pass_filenames from .pre-commit-config.yaml, so there is exactly one source of truth for what “type-checks clean” means. When results still diverge between a laptop and a runner, the cause is almost always the environment, not the code — PYTHONPATH and MYPYPATH change where the checker resolves first-party modules and stub packages, so a stub present locally but absent in CI produces a false positive in one place and a false negative in the other.

Local and CI lanes converge when config and environment match A local lane and a CI lane each run the same pinned pre-commit config with the same PYTHONPATH and MYPYPATH, and both converge on one identical pass-or-fail verdict. Same config + same env → one verdict Local commit developer machine CI runner fresh checkout pinned hook config rev + deps + args PYTHONPATH / MYPYPATH Identical verdict exit 0 = pass exit 1 = block PR
When both lanes run the same pinned config and the same PYTHONPATH/MYPYPATH, they cannot disagree — the exit code maps straight to the PR status check.

Exit codes are what turn a check into a gate. pre-commit run exits non-zero if any hook fails or if a hook modified a file, and mypy itself returns 1 when it reports an error; leave continue-on-error at its default (false) so that non-zero result fails the job and blocks the merge rather than passing with a warning. Pin pre-commit itself in your dev dependencies and set default_language_version: python: python3.11 in the config so every hook venv is built with the same interpreter the code targets, closing another source of local/CI drift. --show-diff-on-failure prints the exact failing hunks in the CI log, and the hosted pre-commit.ci service or a cached pre-commit run step keeps the gate fast. Export PYTHONPATH and MYPYPATH explicitly in the CI job env — and document them for local setup — so stub resolution is byte-for-byte identical on both sides.

Match PYTHONPATH and MYPYPATH between local and CI Differences in PYTHONPATH or MYPYPATH between developer machines and CI runners cause mypy to resolve stubs differently, producing false positives in one environment and false negatives in the other. Set both variables explicitly in your CI job environment and document them for local setup.

Execute pre-commit run --all-files --show-diff-on-failure in CI to surface exact failing lines. This configuration aligns with standardized Pre-commit Hooks Setup templates and ensures consistent gating across matrix runners.

Common Pitfalls & Fixes

  • Missing additional_dependencies: Pre-commit isolates environments. Omitting explicit pins causes ModuleNotFoundError for third-party stubs. Always declare types-* packages.
  • Incorrect pass_filenames setting: Full-repo graph analysis requires pass_filenames: false for type checkers. Otherwise, cross-module imports fail during partial commits. Linters like Ruff can safely use pass_filenames: true.
  • Global Python pollution: Local pip install packages leak into hook execution paths when using language: system. Rely on additional_dependencies or ensure your system Python is clean when using language: system.
  • Unpinned rev or floating stubs: A branch name or missing pin in rev, or an unbounded types-* version, lets the hook environment change silently between runs. A new mypy release or a stub update can introduce fresh errors on unchanged code. Pin the rev tag and constrain stub versions.
  • typing_extensions drift: Importing Self, ParamSpec, or TypeAlias from typing_extensions against a too-old pin raises AttributeError inside the isolated hook venv even though the same import works in your project environment. Pin typing_extensions in additional_dependencies next to the checker.
Symptom, root cause, and fix for common pre-commit failures A three-column table pairs each observable symptom with its underlying cause and the corresponding fix for four common pre-commit type-checking failures. Symptom Root cause Fix ModuleNotFoundError stub not in hook venv add types-* to deps false [attr-defined] pass_filenames: true set pass_filenames: false AttributeError on TypeAlias / ParamSpec typing_extensions too old pin typing_extensions passes local, fails CI PYTHONPATH / MYPYPATH differ export both explicitly
Each observable failure traces to a concrete cause and a one-line fix — most reduce to a missing pin or a mismatched environment.

FAQ

How do I prevent pre-commit from re-checking unchanged files? Pre-commit caches hook results by file hash. Keep additional_dependencies and args static. Avoid --all-files locally to leverage incremental caching.

Why does mypy report “Cannot find implementation” in pre-commit but not locally? Local runs inherit your project’s site-packages. Pre-commit uses an isolated venv. Add missing stubs to additional_dependencies or configure MYPYPATH explicitly.

Can I enforce type validation only on modified lines? Static checkers require full-file AST parsing for accurate inference. Use files: regex to scope hooks to directories. Line-level validation is unsupported by both tools.

How to handle typing_extensions version drift? Pin typing_extensions in additional_dependencies alongside your type checker. Mismatched versions trigger AttributeError on newer syntax like TypeAlias or ParamSpec.

Back to Pre-commit Hooks Setup