Fix: pre-commit Not Working — Hooks Not Running, Install Failures, and CI Issues
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 inOr 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 changeOr 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-commitInstall hooks into the current repo:
cd my-repo
pre-commit install
# pre-commit installed at .git/hooks/pre-commitCommon 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 happenpre-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-pushEach 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-requestsrev 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 latestRun 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 CLIpre-commit autoupdate --exclude https://github.com/astral-sh/ruff-pre-commitFix 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.0Language options for local hooks:
| Language | Behavior |
|---|---|
system | Run arbitrary shell command (must be in PATH) |
python | Create an isolated Python env with additional_dependencies |
node | Create Node env |
docker_image | Run in a Docker image |
fail | Always 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 defaultPro 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 --helpSkip 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 manualFix 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.6Caveat: 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-formatFor 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 hooksWithout 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 graphRun 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 filesCache cleanup (if the cache gets stale):
pre-commit clean # Remove cached environments
pre-commit install-hooks # Re-download freshCommon 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-filesMake 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-printsStill 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:
| Tool | Language | Multi-language hooks | Hosted autofix | Parallel exec | Config |
|---|---|---|---|---|---|
| pre-commit | Python | Yes (ecosystem) | pre-commit.ci | Yes | .pre-commit-config.yaml |
| husky + lint-staged | Node | DIY | No | Sequential | package.json + lint-staged.config.js |
| lefthook | Go (binary) | Yes | No | Yes (default) | lefthook.yml |
| simple-git-hooks | Node | DIY | No | No | package.json |
| Raw .git/hooks | Shell | DIY | No | No | Untracked 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.logView 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.pyCommit 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-msgpre-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: falsepre-commit install --hook-type pre-pushThis 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: ruffCombined 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.hooksPathOr skip selectively:
SKIP=mypy,ruff git rebase -i mainDon’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 initIf 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.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Ruff Not Working — Configuration Errors, Rule Selection, and Format vs Lint Confusion
How to fix Ruff errors — pyproject.toml configuration not applied, rule code unknown, ruff format vs ruff check confusion, ignore not working, per-file-ignores, line-length conflicts, and migrating from Flake8 Black isort.
Fix: Click Not Working — Group Setup, Context Passing, and Parameter Type Errors
How to fix Click errors — UsageError missing argument, Group has no command, ctx.obj not passing between commands, ParamType validation failed, BadOptionUsage no such option, pass_context required, and lazy loading groups.
Fix: Rich Not Working — Live Display Issues, Color in CI, and Console Configuration
How to fix Rich errors — colors not appearing in CI logs, Live display flickering, progress bar not updating, table column overflow, traceback install conflicts, and Console redirect issues.
Fix: Typer Not Working — Argument Errors, Autocomplete, and Subcommand Issues
How to fix Typer errors — type annotation required error, Optional argument parsing, boolean flag conventions, autocomplete installation failed, nested commands not found, and rich traceback disable.