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 isolated virtual environments for each registered hook. Relying on the project’s global environment causes version drift and ModuleNotFoundError during execution. Explicitly pin type checker versions and third-party stubs using additional_dependencies. This ensures deterministic analysis regardless of local site-packages state.

For comprehensive type graph resolution, set pass_filenames: false. When pass_filenames is true, checkers only analyze staged files — this breaks cross-module inference and generates false negatives. Align your local hook architecture with broader Static Analysis Tools & CI Integration pipelines to prevent environment fragmentation.

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

Validate the configuration against modern Python 3.10+ syntax. Type checkers require explicit stubs for newer typing constructs.

# src/models.py
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

Incremental Strictness & Legacy Code Migration

Enforcing strict typing across legacy codebases causes immediate workflow friction. Use targeted flags and directory-scoped overrides to validate new modules while gracefully ignoring technical debt. mypy and pyright handle legacy paths differently: mypy relies on [[tool.mypy.overrides]], while pyright uses exclude arrays and typeCheckingMode.

Restrict hook execution to active development directories using the files: regex. Combine this with per-directory configuration to maintain strict standards for new features. Note that Ruff handles linting and formatting, not deep type inference. Keep Ruff in a separate hook stage to avoid conflating style violations with type errors.

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

[[tool.mypy.overrides]]
module = "legacy_module.*"
ignore_errors = true
ignore_missing_imports = true

[tool.pyright]
typeCheckingMode = "strict"
include = ["src"]
exclude = ["src/legacy_module"]

Parallel Execution & Cache Optimization for Large Repos

Unoptimized type checking can stall commits for minutes. Leverage native caching and AST reuse to reduce execution time. Configure mypy to persist .mypy_cache outside the Git tree to prevent cache invalidation on branch switches.

Exclude heavy directories like venv, .tox, and generated protobuf files via the exclude: regex. Pyright’s --stats flag helps identify bottlenecks during initial tuning.

- name: Cache mypy
  uses: actions/cache@v4
  with:
    path: .mypy_cache
    key: ${{ runner.os }}-mypy-${{ hashFiles('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 environments must produce identical validation results. Standardize PYTHONPATH and MYPYPATH across runners to prevent false positives caused by missing stubs. Map non-zero exit codes directly to PR status checks using continue-on-error: false.

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.

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