Automating Pre-Commit Type Validation for Python Projects

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 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 enabled by default, 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.8.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: true
 - repo: https://github.com/RobertCraigie/pyright-python
 rev: v1.1.348
 hooks:
 - id: pyright
 args: [--ignoreexternal]
 additional_dependencies: [pyright==1.1.348]
 files: ^src/.*\.py$

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 under 10 seconds. Configure mypy to persist .mypy_cache outside the Git tree. This prevents cache invalidation on branch switches.

Exclude heavy directories like node_modules, venv, and generated protobuf files via the exclude: regex. Remove --show-traceback in production hooks to minimize I/O overhead. Pyright’s --stats flag helps identify bottlenecks during initial tuning.

- name: Cache mypy
 uses: actions/cache@v3
 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.

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. It 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 default: Full-repo graph analysis requires pass_filenames: false. Otherwise, cross-module imports fail during partial commits.
  • Global Python pollution: Local pip install packages leak into hook execution paths. Rely exclusively on additional_dependencies or language: system with explicit path mapping.

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.