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.

Pre-commit hook execution flow On git commit, staged files pass to the pre-commit runner, which spins up an isolated virtual environment per hook. Each hook (ruff, mypy) runs in its own venv and returns pass or fail. A failure blocks the commit. git commit staged files pre-commit runner isolated venvs ruff lint + format mypy type check pass / fail blocks commit
pre-commit spawns an isolated virtual environment per hook; both ruff and mypy must pass before the commit is accepted.

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.

Pre-commit hook environment isolation layers A cached per-hook virtual environment sits apart from the project venv and system Python, seeing only its hook plus declared additional_dependencies. What the mypy hook can actually import Host machine System Python not on hook path Project venv requests, pydantic… invisible to the hook walled off Cached hook venv ~/.cache/pre-commit, keyed by rev mypy 1.17.0 additional_dependencies types-requests, pydantic
The hook's environment sees only mypy plus its declared additional_dependencies — never the project or system interpreter — so every stub the checker needs must be listed explicitly.

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.

Ruff vs mypy vs pyright capability matrix A grid comparing the three hooks across type inference, implicit Optional handling, configuration source, and relative speed. One gate, three tools with different jobs ruff mypy pyright type inference none whole-graph incremental implicit Optional ignored [assignment] reportArgumentType config source [tool.ruff] [tool.mypy] [tool.pyright] relative speed fastest slowest fast
Ruff, mypy, and pyright occupy different cells of the same grid; align their strictness deliberately so a commit that passes one does not fail another 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.

Always set pass_filenames: false for type checkers When 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.

Cold versus warm cache CI run Without restored caches a CI run reinstalls environments and rebuilds mypy state, while a warm run restores both and finishes in seconds. Same pipeline, two cache states cold cache build venvs rebuild .mypy_cache ~3 min warm cache restore venvs config-hash key restore .mypy_cache ~15 s cache keys env → hash(config.yaml) mypy → os-py-ver-sha wrong version = discarded, rebuilt
A warm run restores the config-keyed environment cache and the version-keyed mypy cache; mismatch either key and the run silently falls back to a full rebuild.

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.

Triaging a hook-only type-check failure A branching tree routes a failure by its error code and reproducibility toward a missing stub, a version skew, or a genuine type error. mypy hook fails read the error code [import-not-found] [import-untyped] real type error? repro locally? only hook disagrees no repro anywhere add stub to additional_dependencies fix the code it is a genuine bug pre-commit clean rebuild frozen env
Route each hook failure by its error code and reproducibility: missing-stub errors get a dependency, reproducible errors get a code fix, and phantom failures get a cache 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.

Anti-pattern to symptom to fix mapping Three common pre-commit configuration mistakes each map to a distinct observed symptom and a single corrective change. anti-pattern symptom fix pass_filenames: true on mypy/pyright false negatives broken cross-module pass_filenames: false whole-graph run floating rev tag or unpinned non-deterministic CI green becomes red pin rev to a tag bump via autoupdate share raw cache across OS/versions cache discarded silent full rebuild key on os-py-ver per-runner cache
Each anti-pattern has a signature symptom; recognising the symptom points directly at the one-line configuration fix.
  • 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. Set pass_filenames: false (and usually require_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. Pin rev: to an exact tag or commit SHA and bump deliberately with pre-commit autoupdate, reviewing the diff like any other dependency change.
  • Omitting additional_dependencies for 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 add types-requests, pandas-stubs, or the library’s own typed distribution to additional_dependencies, then let pre-commit rebuild.
  • Sharing local cache directories directly with CI runners: mypy silently discards a .mypy_cache written 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 on os-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.

Back to Static Analysis Tools & CI Integration