Skip to content

Fix: pre-commit Not Working — Hooks Not Running, Install Failures, and CI Issues

FixDevs ·

Quick Answer

How to fix pre-commit errors — hooks not triggering on commit, pre-commit install failed, repo local hook not found, autoupdate not working, CI environment cache issues, and skip specific hooks.

The Error

You configure pre-commit but the hooks don’t run on commit:

$ git commit -m "changes"
[main abc1234] changes
# No hooks ran — code committed with lint errors still in

Or installing pre-commit fails:

An error has occurred: FatalError: git failed. Is it installed, and are you in a Git repository directory?

Or a repo’s local hook can’t be found:

  - id: my-custom-check
An unexpected error has occurred: FatalError:
Expected hook id `my-custom-check` to be in `repo: local`.

Or autoupdate silently fails in CI:

pre-commit autoupdate
# Output suggests success but versions don't change

Or a hook passes locally but fails in CI with confusing Python version errors:

InstallationError: Command errored out with exit status 1
ERROR: Package 'ruff' requires a different Python: 3.9.0 not in '>=3.10'

pre-commit is the standard framework for managing Git hooks across Python projects — and increasingly across JS, Go, Rust, and more. It abstracts away hook installation, dependency management, and multi-language environments. But configuration has a specific grammar, and the interaction with Git hooks, CI caches, and language environments produces specific failure modes. This guide covers each.

Why This Happens

pre-commit installs itself as a Git hook by writing to .git/hooks/pre-commit. If you clone a repo fresh, the hooks aren’t installed automatically — you need pre-commit install per clone. If the .git/hooks/pre-commit file already exists (from another tool), pre-commit refuses to overwrite it without --force.

The .pre-commit-config.yaml config specifies repos (hook providers) and hooks to run. Each hook runs in its own isolated environment that pre-commit caches in ~/.cache/pre-commit/. Changes to that config invalidate parts of the cache.

Fix 1: Installing pre-commit and Hooks

pip install pre-commit
# Or with uv
uv tool install pre-commit

Install hooks into the current repo:

cd my-repo
pre-commit install
# pre-commit installed at .git/hooks/pre-commit

Common cause of “hooks not running”:

# Check: does the Git hook actually exist?
cat .git/hooks/pre-commit
# If the file is missing or has different content, install didn't happen

pre-commit install must run once per clone. Add a README note:

## Setup

```bash
git clone ...
cd my-repo
pip install pre-commit
pre-commit install

**Install for multiple stages:**

```bash
pre-commit install --hook-type pre-commit --hook-type commit-msg --hook-type pre-push

Each stage runs different hooks from your config.

Common Mistake: Running pre-commit run --all-files once, seeing it work, and assuming hooks are installed. pre-commit run works without installing the Git hook — it’s the pre-commit install step that makes hooks fire on git commit. Confusion between “runs manually” and “fires on commit” is the single most common pre-commit issue.

Fix 2: Configuration Format

# .pre-commit-config.yaml
repos:
  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v4.5.0
    hooks:
      - id: trailing-whitespace
      - id: end-of-file-fixer
      - id: check-yaml
      - id: check-added-large-files
        args: ["--maxkb=500"]

  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.5.0
    hooks:
      - id: ruff
        args: [--fix, --exit-non-zero-on-fix]
      - id: ruff-format

  - repo: https://github.com/pre-commit/mirrors-mypy
    rev: v1.10.0
    hooks:
      - id: mypy
        additional_dependencies:
          - pydantic
          - types-requests

rev is a git ref — typically a version tag, but can be a branch or commit SHA:

rev: v4.5.0      # Tag (most common)
rev: main        # Branch (risky — breaks reproducibility)
rev: abc1234     # Full commit SHA (reproducible but unclear)

autoupdate pins to the latest tag:

pre-commit autoupdate
# Updates all rev: fields to latest

Run this periodically to pick up bug fixes and new rules.

Skip autoupdate for specific repos:

- repo: https://github.com/astral-sh/ruff-pre-commit
  rev: v0.5.0
  hooks:
    - id: ruff
    - id: ruff-format
  # Exclude from autoupdate via CLI
pre-commit autoupdate --exclude https://github.com/astral-sh/ruff-pre-commit

Fix 3: Local Hooks vs Remote Hooks

Remote hooks pull from a pre-commit repo (like the ones above). Most hooks are remote.

Local hooks run scripts from your own repo without a separate pre-commit hook repo:

repos:
  - repo: local
    hooks:
      - id: custom-check
        name: Run custom validation script
        entry: python scripts/validate.py
        language: system
        files: ^src/.*\.py$

      - id: pytest-quick
        name: Run fast tests
        entry: pytest -x -m "not slow"
        language: system
        pass_filenames: false
        stages: [pre-push]

      - id: check-secrets
        name: Detect secrets in commits
        entry: detect-secrets-hook
        language: python
        additional_dependencies:
          - detect-secrets==1.4.0

Language options for local hooks:

LanguageBehavior
systemRun arbitrary shell command (must be in PATH)
pythonCreate an isolated Python env with additional_dependencies
nodeCreate Node env
docker_imageRun in a Docker image
failAlways fail; use for “this rule always fails on matching files”

Common fields:

- id: my-hook
  name: Human-readable name
  entry: command to run
  language: system
  files: ^src/.*\.py$       # Run only on matching files
  exclude: ^tests/.*        # Skip these
  types: [python]            # Run on Python files (language-aware)
  types_or: [python, yaml]   # Either type
  pass_filenames: true       # Pass matched files as args (default)
  stages: [pre-commit]       # Or pre-push, commit-msg, manual
  require_serial: false      # Run in parallel by default

Pro Tip: For quick custom validation, use language: system with a shell or Python command. Only reach for language: python with additional_dependencies if the hook needs packages that aren’t guaranteed on the developer’s machine. system keeps the hook fast and debuggable.

Fix 4: Running and Skipping Hooks

# Run all hooks on all files
pre-commit run --all-files

# Run a specific hook
pre-commit run ruff --all-files

# Run on staged files only (what happens at commit time)
pre-commit run

# Run on specific files
pre-commit run --files src/main.py src/utils.py

# Show hook IDs
pre-commit run --help

Skip hooks for a single commit (use sparingly):

SKIP=ruff,mypy git commit -m "WIP"

# Skip all hooks
git commit --no-verify -m "emergency fix"

Don’t use --no-verify in team workflows — it bypasses quality gates. Prefer fixing the issue or configuring exclude for specific files that should skip checks.

Manual-only stages — hooks that only run when explicitly invoked:

- id: slow-integration-tests
  stages: [manual]
  entry: pytest tests/integration
  language: system
# Normal commit doesn't run these
git commit -m "changes"

# Explicit invocation
pre-commit run slow-integration-tests --hook-stage manual

Fix 5: additional_dependencies for Python Hooks

Hooks like mypy need to know your project’s dependencies to type-check correctly:

- repo: https://github.com/pre-commit/mirrors-mypy
  rev: v1.10.0
  hooks:
    - id: mypy
      additional_dependencies:
        - pydantic>=2.0
        - fastapi
        - types-requests
        - sqlalchemy[mypy]

Without these, mypy can’t resolve types from third-party libraries and reports false positives.

Pin versions in additional_dependencies to keep hooks reproducible:

additional_dependencies:
  - pydantic==2.7.0
  - types-requests==2.31.0.6

Caveat: pre-commit maintains a separate virtual env per hook. Your project’s pyproject.toml dependencies don’t automatically flow into the hook. If your code uses pydantic==2.7.0 and the hook env has pydantic==1.x, type-checking produces wrong results.

For mypy-specific errors that come up in pre-commit hooks, see Python mypy type error.

For Ruff, the hook bundles its own linting rules — no additional_dependencies needed:

- repo: https://github.com/astral-sh/ruff-pre-commit
  rev: v0.5.0
  hooks:
    - id: ruff
    - id: ruff-format

For Ruff-specific configuration and rule selection, see Ruff not working.

Fix 6: CI Integration

GitHub Actions:

# .github/workflows/pre-commit.yml
name: pre-commit

on:
  pull_request:
  push:
    branches: [main]

jobs:
  pre-commit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - uses: pre-commit/[email protected]

Cache pre-commit environments (makes CI 2-5x faster):

- uses: actions/cache@v4
  with:
    path: ~/.cache/pre-commit
    key: pre-commit-${{ hashFiles('.pre-commit-config.yaml') }}

The key includes a hash of the config — cache invalidates when you bump hook versions.

Common CI failure — different Python version than local:

default_language_version:
  python: python3.12   # Force a specific Python for all Python hooks

Without this, pre-commit uses the default python3 on the CI runner, which may differ from the developer’s local version.

pre-commit.ci — free hosted service that auto-updates config and fixes lint issues:

# .pre-commit-config.yaml
ci:
  autofix_commit_msg: 'style: [pre-commit.ci] auto fixes'
  autoupdate_schedule: weekly
  skip: [mypy]   # Hooks to skip in pre-commit.ci (e.g., those needing secrets)

Sign up at pre-commit.ci and it integrates with GitHub PRs automatically.

Fix 7: Performance Tuning

Hooks run sequentially on matched files. Slow hooks compound — running mypy on 1000 files takes minutes.

Limit files per run:

- id: mypy
  entry: mypy
  language: system
  types: [python]
  files: ^src/   # Only src/, not tests/
  exclude: ^src/migrations/   # Skip auto-generated
  require_serial: true   # mypy needs the whole module graph

Run on changed files only (default behavior of git commit):

git commit   # pre-commit only runs on staged files
pre-commit run   # Same — only staged files
pre-commit run --all-files   # Full scan (slow)

Parallel vs serial:

- id: my-fast-check
  require_serial: false   # Default — runs in parallel across files

- id: my-slow-check-with-state
  require_serial: true   # Runs once with all files

Cache cleanup (if the cache gets stale):

pre-commit clean   # Remove cached environments
pre-commit install-hooks   # Re-download fresh

Common Mistake: Configuring hooks to scan **/*.py including node_modules/, .venv/, build/, or auto-generated files. Every hook run then takes minutes. Always set exclude: for dirs that shouldn’t be checked.

Fix 8: Writing Custom Hook Scripts

For project-specific checks, write a custom hook:

# scripts/check_no_debug_prints.py
import sys
import re

def main(files):
    errors = []
    pattern = re.compile(r'\b(print|breakpoint)\s*\(')
    for filename in files:
        with open(filename) as f:
            for line_no, line in enumerate(f, 1):
                if pattern.search(line) and not line.strip().startswith("#"):
                    errors.append(f"{filename}:{line_no}: {line.strip()}")

    if errors:
        print("Found debug statements:")
        for err in errors:
            print(err)
        return 1
    return 0

if __name__ == "__main__":
    sys.exit(main(sys.argv[1:]))
# .pre-commit-config.yaml
- repo: local
  hooks:
    - id: no-debug-prints
      name: Check for debug print statements
      entry: python scripts/check_no_debug_prints.py
      language: system
      types: [python]
      exclude: ^tests/

Test your custom hook locally:

pre-commit run no-debug-prints --all-files

Make it a shared hook by publishing to a separate repo — other projects can then reference it:

- repo: https://github.com/yourorg/custom-pre-commit-hooks
  rev: v1.0.0
  hooks:
    - id: no-debug-prints

Still Not Working?

Pre-commit vs husky vs lefthook

  • pre-commit — Python-focused, multi-language, widespread in Python community. Best for Python monorepos.
  • husky — Node.js-based, uses npm scripts. Best for JS/TS projects where npm is already the workflow center.
  • lefthook — Go-based, language-agnostic, fast. Good for multi-language monorepos.

Integration with Git Workflow

For Git hook concepts, commit workflow, and how .git/hooks/ integrates with pre-commit, see Git hooks not running.

Debugging Failed Hooks

# Show full output
pre-commit run --verbose

# Show the exact commands being executed
pre-commit run --verbose --all-files 2>&1 | tee pre-commit-debug.log

View a specific hook’s environment:

# Find the cached env
ls ~/.cache/pre-commit/
# repo*-*-...

# Activate it and test manually
source ~/.cache/pre-commit/repo.../py_env-python3.12/bin/activate
mypy your_file.py

Commit Message Hooks

Validate commit messages with commit-msg stage hooks:

- repo: https://github.com/compilerla/conventional-pre-commit
  rev: v3.4.0
  hooks:
    - id: conventional-pre-commit
      stages: [commit-msg]
      args: []

Enforces Conventional Commits format (feat:, fix:, chore:, etc.). Install for this stage:

pre-commit install --hook-type commit-msg

pre-push vs pre-commit Hooks

Fast hooks go in pre-commit (run on every commit). Slow hooks (full test suite, security scans) go in pre-push:

- id: pytest-full
  stages: [pre-push]
  entry: pytest
  language: system
  pass_filenames: false
pre-commit install --hook-type pre-push

This keeps commits fast (<5 seconds) while still catching failures before they reach the remote.

Ruff + mypy in the Same Config

Run fast linters first (they fail quickly), slow ones last:

repos:
  # Fast — runs first
  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v4.5.0
    hooks:
      - id: trailing-whitespace
      - id: end-of-file-fixer

  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.5.0
    hooks:
      - id: ruff
        args: [--fix]
      - id: ruff-format

  # Slower — runs last
  - repo: https://github.com/pre-commit/mirrors-mypy
    rev: v1.10.0
    hooks:
      - id: mypy
        additional_dependencies: [pydantic, types-requests]

pre-commit runs hooks in the order they appear in the config. Fast-fail order means you catch 90% of issues in the first few seconds.

For general pytest-related checks that developers often add to pre-commit, see pytest fixture not found.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles