Pre-commit Hooks Setup for Python Static Analysis
Establishing a deterministic pre-commit workflow enforces type hints and static analysis before code enters version control. This approach aligns local developer environments with continuous integration pipelines.
Isolating hook execution environments prevents dependency conflicts across projects. Deterministic caching enables sub-second type validation during iterative development. Hook strictness must scale with project maturity and team velocity. Local execution should mirror broader Static Analysis Tools & CI Integration standards to eliminate environment drift.
Environment Isolation & Hook Architecture
Pre-commit creates a dedicated, cached virtual environment for each hook repository under ~/.cache/pre-commit/, keyed by the repo URL and the pinned rev. When a hook first runs, pre-commit clones the repo at that revision, builds an isolated environment for the declared language (for mypy and ruff that is python), installs the hook plus any additional_dependencies, and reuses that environment on every subsequent commit until the rev or dependency list changes. The host interpreter’s site-packages is never on the path, which is precisely why a type checker running under pre-commit cannot see the libraries installed in your project venv — it only sees what you list in additional_dependencies.
That isolation is the single most important thing to understand about running mypy or pyright here. Your project’s requests, pydantic, or sqlalchemy are invisible to the hook’s environment, so their bundled or third-party stubs are invisible too. mypy responds with error: Cannot find implementation or library stub for module named "requests" [import-not-found] (older versions emitted [import]), and unless you have set ignore_missing_imports, every symbol imported from that module silently degrades to Any, defeating the check. The fix is to declare the stubs the checker needs — types-requests for the requests stubs, and the runtime package itself (e.g. pydantic) when the library ships inline types in a py.typed marker per PEP 561.
Execution order directly impacts latency, so the ordering of hooks is a deliberate design choice rather than cosmetic. Place fast formatters and auto-fixers (ruff, ruff-format) before heavy type checkers: if ruff rewrites a file with --fix, pre-commit aborts the run and asks you to re-stage, and there is no point having mypy parse a file that is about to change. Ruff operates per file in milliseconds, while mypy and pyright build a whole-program dependency graph and benefit from analyzing a stable snapshot.
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.14.0
hooks:
- id: ruff
args: [--fix, --exit-non-zero-on-fix]
types_or: [python, pyi]
- id: ruff-format
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.17.0
hooks:
- id: mypy
args: [--strict, --show-error-codes]
additional_dependencies: [types-requests, pydantic]
pass_filenames: false
The configuration above demonstrates isolated dependency injection. Disabling filename passing forces mypy to analyze the entire project graph rather than the handful of staged paths pre-commit would otherwise append to the command. The --cache-dir location defaults to .mypy_cache; you can override it with --cache-dir=.mypy_cache explicitly to make it visible in your .gitignore. One subtle failure mode: because the hook environment is frozen at the pinned rev, bumping mypy locally in your project venv does nothing to the hook — you must update the rev and run pre-commit autoupdate (or edit it by hand) so the two interpreters cannot drift out of sync and report different diagnostics for the same file. The types_or: [python, pyi] selector also matters: without listing pyi, hand-written stub files escape checking entirely, which is a common blind spot in libraries that ship their own .pyi surfaces.
Type Checker Integration & Strictness Alignment
Type checkers diverge significantly in performance, defaults, and configuration syntax, and pre-commit is where those differences first bite a team. Ruff handles linting and import sorting in milliseconds but performs no type inference. mypy prioritizes exhaustive type narrowing and is strict about Optional, but requires full context and reads its settings from [tool.mypy] in pyproject.toml or mypy.ini. Pyright (and its user-facing CLI wrapper basedpyright) offers faster incremental analysis, is stricter about reportUnknown* categories, and reads [tool.pyright] or a separate pyrightconfig.json. Because each tool has its own notion of “strict,” aligning them into a single, predictable gate is the real work of this section.
The starkest concrete difference is how each treats an implicit Optional. Under --strict, mypy enforces no_implicit_optional (the default since mypy 0.990 / Python packaging era 2022), so a parameter def f(x: int = None) raises error: Incompatible default for argument "x" (default has type "None", argument has type "int") [assignment]. Pyright flags the same line as reportArgumentType. Ruff never comments on it at all — it is not a type checker. Keeping this matrix in mind prevents the frustrating situation where a file passes ruff cleanly, passes mypy, and still fails pyright in CI.
Aligning strictness tiers prevents developer friction during onboarding. Apply args: [--strict] alongside targeted exclude patterns for legacy modules. Reference Mypy Configuration & Strictness baselines when defining tiered validation rules, and see the Pyright vs Mypy Comparison for where the two disagree on type narrowing and protocol variance.
pass_filenames: true (the default), mypy and pyright receive only the staged files — breaking cross-module inference and generating false negatives. Set pass_filenames: false so the checker analyzes the full project graph. Ruff, by contrast, can safely use per-file mode.
Large-scale migrations benefit from incremental strictness. Use --follow-imports=skip to isolate new modules from untyped dependencies during early migration, then switch to --follow-imports=silent once stubs are in place. Two mypy modes deserve special mention in a pre-commit context: --follow-imports=skip treats imported-but-unchecked modules as Any without erroring, which keeps early runs green, whereas --follow-imports=error will actively fail on any module it is asked to skip — useful once you want strictness to ratchet only upward.
[tool.mypy]
strict = true
warn_return_any = true
warn_unused_ignores = true
no_implicit_optional = true
ignore_missing_imports = false
exclude = ["^tests/fixtures/", "^legacy/"]
[tool.pyright]
typeCheckingMode = "strict"
reportMissingTypeStubs = "warning"
exclude = ["tests/fixtures", "legacy"]
[tool.ruff]
target-version = "py310"
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "N", "TC"]
The TC (formerly TCH) ruleset above is worth calling out: it flags imports used only in annotations and pushes them into an if TYPE_CHECKING: block, which both speeds runtime imports and interacts directly with from __future__ import annotations. Under PEP 563, that future import turns every annotation into a string, so a symbol referenced only inside annotations never needs to exist at runtime — exactly the class of import TC moves. mypy and pyright both read those stringized annotations statically, so the check still sees the real type. The following Python 3.10+ module triggers strict validation across all three tools:
from __future__ import annotations
from typing import Protocol, runtime_checkable
@runtime_checkable
class DataProcessor(Protocol):
def transform(self, payload: bytes) -> dict[str, list[int]]: ...
def execute(processor: DataProcessor, raw: bytes) -> dict[str, list[int]]:
# Both mypy and pyright enforce structural conformance to DataProcessor
return processor.transform(raw)
Note that on the dict[str, list[int]] builtin-generic syntax: it is legal at runtime only on Python 3.9+ (PEP 585), but with from __future__ import annotations the annotation is never evaluated, so the same source runs on 3.8 while still type-checking correctly. That interaction — modern syntax guarded by the future import — is a frequent reason a file passes the type checkers in the hook yet must still declare a target-version.
CI/CD Pipeline Synchronization & Cache Optimization
Local hooks must execute identically in continuous integration environments, and the canonical way to guarantee that is to run the exact same .pre-commit-config.yaml in CI via pre-commit run --all-files rather than invoking mypy or ruff directly. Doing so means the CI job resolves the same pinned revs, builds the same isolated environments, and applies the same additional_dependencies — so a green local commit and a green pipeline are testing genuinely the same thing. Two caches then matter independently: pre-commit’s own environment cache under ~/.cache/pre-commit, keyed by the hashed config, and each tool’s incremental cache (.mypy_cache, .ruff_cache). Restore both and a cold, minutes-long run collapses to seconds.
On GitHub Actions the two caches have different natural keys. The pre-commit environment cache should key on the hash of .pre-commit-config.yaml (it only changes when a hook rev or dependency list changes), while the mypy incremental cache is far more volatile and benefits from a rolling key. mypy stores per-module JSON and a data hash in .mypy_cache/<version>/; if the cache was written by a different mypy version or Python minor version it is silently discarded and rebuilt, which is why cross-version cache sharing is a false economy.
The dedicated pre-commit/action used to handle this automatically, but the current recommended pattern is an explicit actions/cache step so you control the keys precisely:
- uses: actions/setup-python@v5
with:
python-version: "3.13"
- uses: actions/cache@v4
with:
path: ~/.cache/pre-commit
key: pre-commit-${{ hashFiles('.pre-commit-config.yaml') }}
- uses: actions/cache@v4
with:
path: .mypy_cache
key: mypy-3.13-${{ hashFiles('**/*.py') }}
restore-keys: mypy-3.13-
- run: pip install pre-commit && pre-commit run --all-files --show-diff-on-failure
Restrict always_run: true to baseline validation hooks only. Overusing this flag degrades pipeline throughput. Configure fail_fast: false to surface all violations in a single execution pass — otherwise the first failing hook aborts the run and a developer fixes one error only to discover three more on the next push. Pin hook repository revisions to guarantee reproducible builds across branches; floating tags introduce unexpected dependency updates that manifest as CI failures on unrelated pull requests. When you run mypy in CI with a restored cache, prefer --no-incremental only in the rare “trust nothing” audit build, because incremental mode is exactly what makes the warm path fast.
Debugging Hook Failures & Incremental Adoption
Environment drift causes false positives during rollout, and the first diagnostic question is always whether a failure comes from the hook’s frozen environment or from your code. Run pre-commit run --show-diff-on-failure to map violations to exact code changes; for auto-fixing hooks this prints the diff pre-commit applied so you can tell “ruff reformatted my file” apart from “mypy found a real bug.” When a mypy error appears in the hook but not when you run mypy in your project venv, the cause is almost always the isolated environment: a stub package present in your venv but absent from additional_dependencies, or a mypy version skew between the pinned rev and your local install. Reproduce the hook’s exact environment with pre-commit run mypy --all-files --verbose, which prints the resolved environment path and the full command line.
A short decision procedure resolves the great majority of “it fails only in the hook” reports. If the error code is [import-not-found] or [import-untyped], a stub or the runtime package is missing from additional_dependencies. If the error is a genuine type error that reproduces with a matching local mypy version, it is real — fix the code. If it reproduces nowhere and only the hook disagrees, clear the environment cache with pre-commit clean (which deletes ~/.cache/pre-commit) and let it rebuild.
Use SKIP=hook_id pre-commit run for temporary bypass during emergency merges, and reserve git commit --no-verify for true break-glass moments — it skips every hook and is easy to make a habit of. Document all bypasses in pull request descriptions so CI, which does not honor local skips, remains the backstop. Profile execution latency with time pre-commit run --all-files to identify bottlenecks; mypy is almost always the long pole, and a genuinely slow first run followed by fast subsequent runs simply means the incremental cache is doing its job.
Transition from advisory to blocking mode using Automating pre-commit type validation workflows. Gradual enforcement reduces merge conflicts on large or legacy codebases. A practical adoption ladder is: run mypy with --follow-imports=skip and no --strict so only egregious errors surface; add per-module overrides that opt individual packages into strictness as they are cleaned up; then flip the global default. mypy supports exactly this with [[tool.mypy.overrides]] blocks:
[tool.mypy]
strict = false
follow_imports = "skip"
[[tool.mypy.overrides]]
module = ["app.payments.*", "app.api.*"]
strict = true # these packages are fully typed — hold them to the bar
disallow_untyped_defs = true
Because pre-commit runs the same config, moving a package into the strict list is a one-line change that immediately begins gating new commits touching it, without disturbing the untyped remainder. Monitor violation trends with mypy --strict . | wc -l on a schedule before raising the global threshold, and keep the numbers falling rather than flipping the switch prematurely.
Common Mistakes & Mitigation
Nearly every recurring pre-commit typing problem traces back to one of a small set of configuration mistakes, and each has a characteristic symptom that lets you name it on sight. The mapping below pairs the anti-pattern with the failure it produces and the one-line fix; the detailed list follows.
- Running type checkers with
pass_filenames: true: This is the default, and it hands mypy or pyright only the staged files. Cross-module inference collapses because the checker cannot see the definitions the changed file depends on, so a call that violates a signature in an untouched module passes silently — a false negative that CI, running the whole graph, later catches. Setpass_filenames: false(and usuallyrequire_serial: true) for mypy and pyright; keep per-file mode only for ruff, which is genuinely file-local. - Relying on unpinned or floating hook repository revisions: A
rev:pointing at a branch or a moving tag means each machine resolves a different hook version, so a run that is green today turns red tomorrow with no code change — often surfacing as a new error code the upstream tool just started emitting. Pinrev:to an exact tag or commit SHA and bump deliberately withpre-commit autoupdate, reviewing the diff like any other dependency change. - Omitting
additional_dependenciesfor stubs: When a hook’s isolated environment lacks a stub package that your local venv happens to have, mypy reports[import-untyped]or[import-not-found]only inside the hook. Because the environment is frozen per config hash, the fix is to addtypes-requests,pandas-stubs, or the library’s own typed distribution toadditional_dependencies, then let pre-commit rebuild. - Sharing local cache directories directly with CI runners: mypy silently discards a
.mypy_cachewritten by a different Python minor version or platform and rebuilds from scratch, so a “shared” cache buys nothing and can mask staleness. Key CI caches onos-pythonversion-<hash>and keep each runner’s cache separate.
Frequently Asked Questions
Should I run pre-commit hooks on all files or only staged changes?
Run linters on staged files for speed. Execute type checkers with pass_filenames: false on the full repository to maintain inference accuracy.
How do I reduce pre-commit hook execution time in large monorepos?
Enable persistent caching. Exclude generated and legacy directories via exclude: regex. Parallelize independent hooks using stages: [pre-commit].
Can pre-commit hooks replace CI type checking? No. Pre-commit provides immediate local feedback. CI must re-validate to catch environment drift and enforce branch protection policies.
How do I handle third-party library type stubs in pre-commit?
Install required stubs via additional_dependencies in the hook configuration. This ensures consistent resolution across all developer machines, independent of their local virtual environment.