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 isolated virtual environments for each hook repository. This guarantees reproducible execution regardless of the host system. You must declare explicit additional_dependencies for type stubs.

Global site-packages are automatically excluded. This prevents silent failures caused by mismatched package versions. Full-project type scanners require pass_filenames: false to maintain cross-module inference accuracy.

Execution order directly impacts latency. Place fast formatters and linters before heavy type checkers. This prevents redundant AST parsing on already-modified files.

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. 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.

Type Checker Integration & Strictness Alignment

Type checkers diverge significantly in performance and configuration syntax. Ruff handles linting and import sorting in milliseconds. mypy prioritizes exhaustive type narrowing but requires full context. Pyright offers incremental analysis with different configuration semantics.

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.

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.

Evaluate Pyright vs Mypy Comparison benchmarks before selecting a primary checker. 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.

[tool.mypy]
strict = true
warn_return_any = true
ignore_missing_imports = false
exclude = ["^tests/fixtures/", "^legacy/"]

[tool.ruff]
target-version = "py310"

[tool.ruff.lint]
select = ["E", "F", "I", "UP", "N", "TCH"]

Decouple hook configuration from project defaults using targeted exclusions. Modern Python targeting ensures compatibility with structural pattern matching. The following example demonstrates a Python 3.10+ module that triggers strict validation:

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]]:
    # Type checker enforces return type alignment
    return processor.transform(raw)

CI/CD Pipeline Synchronization & Cache Optimization

Local hooks must execute identically in continuous integration environments. Share .mypy_cache and .ruff_cache across runners via artifact storage. This eliminates redundant computation during parallel job execution.

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.

Pin hook repository revisions to guarantee reproducible builds across branches. Floating tags introduce unexpected dependency updates. Cache directories should be archived using runner-specific path mappings.

Debugging Hook Failures & Incremental Adoption

Environment drift causes false positives during rollout. Run pre-commit run --show-diff-on-failure to map violations to exact code changes. This output clarifies whether failures stem from local state or actual type errors.

Use SKIP=hook_id pre-commit run for temporary bypass during emergency merges. Document all bypasses in pull request descriptions. Profile execution latency with time pre-commit run --all-files to identify bottlenecks.

Transition from advisory to blocking mode using Automating pre-commit type validation workflows. Gradual enforcement reduces merge conflicts. Monitor violation trends before raising strictness thresholds.

Common Mistakes & Mitigation

  • Running type checkers with pass_filenames: true: Causes fragmented analysis and false negatives. Type checkers require full project context for accurate cross-module inference. Use pass_filenames: false for mypy and pyright.
  • Relying on unpinned hook repository revisions: Leads to non-deterministic CI failures. Upstream hooks frequently introduce breaking changes or unexpected dependency updates. Always pin rev: to a specific tag or commit.
  • Sharing local cache directories directly with CI runners: Creates cross-platform cache corruption. Differing OS filesystem semantics and Python patch versions invalidate cached state. Use CI-specific cache keys.

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