Automating Pre-Commit Type Validation for Python Projects
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.
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.
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.
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.
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.
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.
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 causesModuleNotFoundErrorfor third-party stubs. Always declaretypes-*packages. - Incorrect
pass_filenamessetting: Full-repo graph analysis requirespass_filenames: falsefor type checkers. Otherwise, cross-module imports fail during partial commits. Linters like Ruff can safely usepass_filenames: true. - Global Python pollution: Local
pip installpackages leak into hook execution paths when usinglanguage: system. Rely onadditional_dependenciesor ensure your system Python is clean when usinglanguage: system. - Unpinned
revor floating stubs: A branch name or missing pin inrev, or an unboundedtypes-*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 therevtag and constrain stub versions. typing_extensionsdrift: ImportingSelf,ParamSpec, orTypeAliasfromtyping_extensionsagainst a too-old pin raisesAttributeErrorinside the isolated hook venv even though the same import works in your project environment. Pintyping_extensionsinadditional_dependenciesnext to the checker.
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.