Skip to content

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

FixDevs · (Updated: )

Part of:  Python Errors

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 Other Git Hook Managers

pre-commit is Python-centric. For non-Python projects, the trade-offs change. Knowing the alternatives saves you from forcing pre-commit on a project where another tool fits better.

pre-commit (Python). Multi-language hook framework written in Python. Strengths: huge ecosystem of pre-built hooks (the pre-commit-hooks repo alone has dozens), reproducible isolated environments per hook, and pre-commit.ci for hosted autofix. Weaknesses: requires Python installed on every developer machine (a problem in JS-only or Rust-only teams), and the YAML schema is verbose for simple hooks.

pre-commit vs husky + lint-staged. Husky writes shell scripts to .husky/ (or .git/hooks/ on older versions); lint-staged runs commands on staged files only. Together they cover the same ground as pre-commit. Strengths: pure Node, integrates with package.json scripts, no separate config language. Weaknesses: linting is up to you to assemble (no equivalent of pre-commit’s hook repo ecosystem), and lint-staged + husky is two dependencies instead of one. Pick husky + lint-staged for JS/TS projects where the team already runs npm install on clone.

pre-commit vs lefthook. Lefthook is a Go binary — no runtime dependency on Python or Node. Configuration is a single lefthook.yml. Strengths: fast (Go startup vs Python startup is noticeable on every commit), language-agnostic, runs hooks in parallel by default with explicit concurrency control. Weaknesses: smaller community than pre-commit, no equivalent of pre-commit.ci. Best for polyglot monorepos where neither Python nor Node is universal. For lefthook-specific config issues, see lefthook not working.

pre-commit vs simple-git-hooks. A minimalist Node alternative — just maps hook names to commands in package.json. No environment isolation, no parallelism, no hook stages. Strengths: dead simple (one config key). Weaknesses: misses everything pre-commit does for you. Pick this only for solo projects where the entire “hook” is one npm run lint command.

pre-commit vs raw .git/hooks/ scripts. You can skip the framework entirely — write a shell script in .git/hooks/pre-commit and check it in via a symlink or copy step. Strengths: zero dependencies. Weaknesses: every developer needs to manually copy/symlink (no pre-commit install), no parallel execution, no per-language sandboxes, and .git/hooks/ isn’t tracked by Git. Reasonable only for personal repos or training exercises.

Cross-language vs JS-only comparison:

ToolLanguageMulti-language hooksHosted autofixParallel execConfig
pre-commitPythonYes (ecosystem)pre-commit.ciYes.pre-commit-config.yaml
husky + lint-stagedNodeDIYNoSequentialpackage.json + lint-staged.config.js
lefthookGo (binary)YesNoYes (default)lefthook.yml
simple-git-hooksNodeDIYNoNopackage.json
Raw .git/hooksShellDIYNoNoUntracked scripts

Pro Tip: Match the tool to the team’s primary language. A Python-heavy team using husky pays for Node startup on every commit (and Node isn’t always installed). A JS-heavy team using pre-commit pays for Python startup on every commit (and Python isn’t always installed). Lefthook is the neutral answer when neither language dominates.

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.

Running the full pytest suite at every commit is too slow for most teams — push slow tests to pre-push and keep pre-commit to fast static checks only.

Pinning Hook Versions Across a Monorepo

In a monorepo with multiple teams editing .pre-commit-config.yaml, version drift causes “works on my machine” failures. Lock the versions explicitly and document the policy:

# .pre-commit-config.yaml
default_language_version:
  python: python3.12
  node: "20.10.0"

default_stages: [pre-commit]   # All hooks default to pre-commit stage

repos:
  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.5.0      # Pinned — only bump via PR
    hooks:
      - id: ruff

Combined with the pre-commit-${{ hashFiles('.pre-commit-config.yaml') }} cache key in CI (Fix 6), version bumps are reproducible and rollbackable.

Running Hooks Selectively on Merge / Rebase

By default, hooks run on every commit — including rebase commits. For interactive rebases that touch dozens of commits, this is painful. Disable hooks for rebase via Git config:

# Per-repo
git config core.hooksPath /dev/null
git rebase -i main
git config --unset core.hooksPath

Or skip selectively:

SKIP=mypy,ruff git rebase -i main

Don’t permanently disable hooks for rebases — they catch real issues. Skip surgically, then run pre-commit run --all-files once after the rebase finishes.

Migrating a Project From Husky to pre-commit (or vice versa)

The migration is straightforward but easy to half-finish. The two systems both write to .git/hooks/pre-commit and only the last installer wins. To switch cleanly:

# Remove old husky install
npx husky uninstall
rm -rf .husky/

# Or remove pre-commit install
pre-commit uninstall

# Then install the new tool
pre-commit install
# or
npx husky init

If you skip the uninstall step, the old tool’s .git/hooks/pre-commit file may persist and conflict. Inspect .git/hooks/pre-commit after install to confirm the right tool is in charge.

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