cheat sheet
pre-commit
Package-level reference for pre-commit on PyPI — install variants, hook-pinning model, and how it composes with ruff, black, and mypy.
pre-commit
What it is
pre-commit is a multi-language framework for managing git hooks, written by Anthony Sottile. It reads .pre-commit-config.yaml at the repo root, materialises each hook into an isolated environment (Python venv, npm install, Rust binary, etc.), and runs them against staged files when you git commit.
The pitch isn't "Python linter" — pre-commit itself is Python but it orchestrates hooks written in any language. The Python ecosystem just adopted it most enthusiastically because pinned hook revisions solve the "every contributor has a different black version" problem cleanly.
Install
pip install pre-commit
Output: (none — exits 0 on success)
uv add --dev pre-commit
Output: dependency added to the dev group in pyproject.toml
poetry add --group dev pre-commit
Output: updated lockfile + dev install
pipx install pre-commit
Output: installed to isolated venv, pre-commit CLI on PATH (recommended — pre-commit is a global tool, not a project dependency)
brew install pre-commit
Output: installed via Homebrew on macOS.
After install you wire it into the repo:
pre-commit install
Output: writes .git/hooks/pre-commit to invoke pre-commit run. From now on every git commit triggers the configured hooks.
Versioning & Python support
- Current major line is
4.x(released 2024).3.xshipped through 2023–2024;2.xthrough 2020–2022. - Releases follow rough semver — minor releases add features, majors drop Python compat or remove deprecated config keys.
- Recent releases require Python 3.9+; the project drops one Python minor per major release.
- Hook invocations run in language-specific environments (Python, Node, Rust, Go, Docker, …) — pre-commit itself only needs Python to drive them.
Package metadata
- Maintainer: Anthony Sottile (
asottile), with community contributors - Project home: github.com/pre-commit/pre-commit
- Docs: pre-commit.com
- PyPI: pypi.org/project/pre-commit
- License: MIT
- Governance: single primary maintainer + community contributors
- First released: 2014
- Downloads: tens of millions per month
Optional dependencies & extras
pre-commit ships no PyPI extras — the package is a thin orchestrator (~1 MB) that delegates everything to external hook repositories. Hook authors publish their own repos (pre-commit-hooks/pre-commit-hooks, astral-sh/ruff-pre-commit, psf/black-pre-commit-mirror, pre-commit/mirrors-mypy, …) and you reference them in .pre-commit-config.yaml:
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.6.4
hooks:
- id: ruff
- id: ruff-format
- repo: https://github.com/psf/black-pre-commit-mirror
rev: 24.10.0
hooks:
- id: black
Output: on the next git commit, pre-commit clones each repo (cached under ~/.cache/pre-commit/), builds the environment, and runs each hook against staged files.
Core runtime deps: cfgv (config validation), identify (file-type detection), nodeenv, pyyaml, virtualenv.
Alternatives
| Package | Trade-off |
|---|---|
husky (Node) | The Node ecosystem equivalent. Lighter, JS-only hook environments. Use in JS-first repos. |
lefthook (Go) | Single binary, no Python dependency. Faster startup; smaller plugin ecosystem. |
lint-staged (Node) | Runs commands against staged files. Often paired with husky. |
overcommit (Ruby) | Older, Ruby-first equivalent. Use only in legacy Ruby shops. |
Direct .git/hooks/pre-commit scripts | No framework, no pinning, no isolation. Don't — they break the moment a contributor's environment differs. |
pre-commit.ci | Hosted SaaS that runs your pre-commit config in CI and auto-updates pinned revs. Complement, not replacement. |
Common gotchas
- Hooks are pinned to
rev:SHAs and drift over time. A six-month-old config silently uses six-month-oldruffrules. Runpre-commit autoupdateperiodically (and re-test) to bump everyrev:to the latest tag. pre-commit installvspre-commit uninstalllifecycle. Cloning a repo doesn't activate hooks — every new clone needspre-commit install. Add it to your README; considerpre-commit install --hook-type commit-msgfor commit-message validators.- Hooks only see staged files by default. A modified-but-unstaged file is ignored.
pre-commit run --all-filesruns across the whole tree — use this in CI and afterpre-commit autoupdate. - Hook-author auth model is "any GitHub user". A
rev:is a git SHA — there's no per-hook signing or verification. Treat.pre-commit-config.yamllike a dependency manifest: review what repos you're pulling. - Cache lives in
~/.cache/pre-commit/. When a hook environment misbehaves after an upgrade,pre-commit cleanblows it away. Heavy environments (e.g.mirrors-mypy) can take a minute to rebuild. - CI usage requires a Python interpreter even for non-Python hooks. A Rust-only project using
cargo fmtvia pre-commit still installs Python in CI. Some teams preferlefthookto avoid the Python step. fail_fast: truestops on the first failure. Default behaviour is to run every hook and report all failures — usually what you want, since fixing one error often surfaces another. Override carefully.stages:matters.stages: [commit](default) runs ongit commit.stages: [push]runs ongit push. Forgetting to set this on a slow hook (e.g. integration tests) makes every commit unbearable.
Real-world recipes
Layered config for a typical Python service
# .pre-commit-config.yaml
default_install_hook_types: [pre-commit, pre-push, commit-msg]
default_stages: [pre-commit]
fail_fast: false
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-added-large-files
args: [--maxkb=500]
- id: check-merge-conflict
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.6.4
hooks:
- id: ruff
args: [--fix, --exit-non-zero-on-fix]
- id: ruff-format
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.13.0
hooks:
- id: mypy
stages: [pre-push] # too slow for every commit
additional_dependencies:
- types-requests
- pydantic
- repo: https://github.com/google/yamlfmt
rev: v0.13.0
hooks:
- id: yamlfmt
stages: [pre-push]
- repo: local
hooks:
- id: pytest-check
name: pytest (changed paths)
entry: pytest
language: system
types: [python]
stages: [pre-push]
pass_filenames: false
Three tiers by speed:
- pre-commit (fast) — whitespace, ruff, format. Sub-second total. Run on every
git commit. - pre-push (heavy) — mypy, yamlfmt, pytest. Run only when pushing. The friction of slow checks shifts to the rare push, not the frequent commit.
- Local hooks —
language: systeminvokes commands directly without spinning up an isolated env. Use for tools that are already on PATH (pytest, internal scripts).
Authoring a local hook
- id: check-no-print
name: forbid print() in src/
entry: scripts/check-no-print.sh
language: script
files: ^src/.*\.py$
#!/usr/bin/env bash
# scripts/check-no-print.sh
if grep -nH '^\s*print(' "$@"; then
echo "print() found — use loguru" >&2
exit 1
fi
Output: (script file — runs from pre-commit when a Python file under src/ changes)
language: script invokes the file directly; language: system calls a command on PATH; language: python builds a venv from additional_dependencies. Pick script for project-internal one-offs, python for portable hooks you might publish.
Hook chaining with fixes
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.6.4
hooks:
- id: ruff
args: [--fix, --exit-non-zero-on-fix]
- repo: https://github.com/psf/black-pre-commit-mirror
rev: 24.10.0
hooks:
- id: black
--exit-non-zero-on-fix makes ruff fail the commit even when it succeeds at auto-fixing. The contributor re-runs git add to stage the fixed files, then git commit. Without this flag, ruff silently fixes and the commit goes through with the fixed code unstaged — split commits and confusion.
Skipping a hook on a specific commit
SKIP=mypy git commit -m "wip: refactor"
Output: commit proceeds; every configured hook except mypy runs.
The SKIP=hook_id,hook_id env var lets the contributor skip a hook intentionally. --no-verify skips all hooks — almost always the wrong tool. Coach toward SKIP=... for surgical bypass.
Performance tuning
pre-commit's overhead is mostly hook-internal (running ruff, mypy, etc.). The framework's own levers:
fail_fast: truestops at the first failure. Default is to run every hook — usually better, since one fix often surfaces another. Usefail_fastonly for slow hooks where time-to-feedback dominates.stages:partition hooks by trigger.[pre-commit]runs on commit (fast hooks);[pre-push]on push (slower hooks).[commit-msg]is for hooks that read the commit message (commitizen, conventional-commits).files:andexclude:scope hooks to relevant paths.files: ^src/.*\.py$runs the hook only on Python files undersrc/. Saves the hook from being invoked at all for unrelated files.language_version:pin the runtime (e.g.python3.12) so the hook environment matches dev expectations. Pre-commit otherwise picks the first available Python — usually fine, occasionally surprising.additional_dependencies:forlanguage: pythonhooks. Adds plugins or type stubs to the hook's venv. The full list is part of the environment hash — adding/removing triggers a rebuild.- Cache lives in
~/.cache/pre-commit/— per-repo, per-rev, per-language-version.pre-commit cleanpurges. CI runners benefit from caching this directory.
pre-commit run --all-files runs across the whole tree (ignoring git's staging) — use in CI and after autoupdate. Without --all-files, pre-commit only sees the staged set, which is often empty in CI.
Configuration & layout patterns
.pre-commit-config.yaml lives at the repo root. Conventions:
default_stages— set globally so individual hooks only override when needed.default_stages: [pre-commit]matches the implicit default but documents intent.default_install_hook_types— controls which git hookspre-commit installwires up. Default is[pre-commit]; addpre-pushandcommit-msgfor hooks that target those stages.exclude:at the top level — repo-wide exclusions (exclude: ^(generated|vendor)/). Override per-hook withfiles:.minimum_pre_commit_version:— refuses to run on older pre-commit installations. Useful if your config uses features added in 3.x or 4.x.ci:—pre-commit.ci(hosted runner) reads this block.autoupdate_schedule: weeklykeeps revs current.
Monorepo strategy — one root .pre-commit-config.yaml with exclude: patterns scoping each hook to its sub-project. Avoid per-sub-project configs; pre-commit only reads the closest config to the staged file, which causes inconsistent behaviour across PRs.
Version migration guide
pre-commit bumps majors slowly:
pre-commit 4.x (2024)
- Python 3.8 dropped.
stages:key renamed values:commit→pre-commit,push→pre-push,merge-commit→pre-merge-commit. Old names work as aliases for now but emit a deprecation warning.--show-diff-on-failureis now the default forpre-commit run --all-filesin CI environments.
pre-commit 3.x (2023)
- Python 3.7 dropped.
language: python_venvremoved;language: pythonis the canonical form.
Hook rev management — pre-commit autoupdate bumps every rev: to the latest tag. Run on a schedule (monthly) and review the diff: hook rule changes can introduce new failures. The pre-commit.ci hosted service automates this with weekly PRs.
Upgrade pattern:
- Pin
minimum_pre_commit_version:in the config. - Bump pre-commit itself separately from hook bumps. Bumping the framework is rarely disruptive; bumping hook revs frequently is.
- After
pre-commit autoupdate, runpre-commit run --all-filesand review the diff before merging.
Plugin & rule ecosystem
There's no plugin API per se — pre-commit invokes external repos as hooks. The "ecosystem" is the universe of *-pre-commit repos and the standard pre-commit-hooks collection. The most-referenced upstream repos:
| Repo | What it provides |
|---|---|
pre-commit/pre-commit-hooks | Stdlib hooks: trailing-whitespace, end-of-file-fixer, check-yaml, check-json, debug-statements, check-merge-conflict, … |
astral-sh/ruff-pre-commit | ruff and ruff-format. |
psf/black-pre-commit-mirror | black. Mirror so pre-commit autoupdate can pin to PSF-tagged releases. |
pre-commit/mirrors-mypy | mypy. Mirror repo because mypy itself doesn't tag releases for pre-commit. |
gitleaks/gitleaks | Secret scanner. |
commitizen-tools/commitizen | Conventional-commits enforcement on the commit-msg stage. |
igorshubovych/markdownlint-cli | Markdown linting. |
adrienverge/yamllint | YAML linting. |
hadolint/hadolint | Dockerfile linting. |
pre-commit/pygrep-hooks | Tiny grep-based smell checks (forbid pdb.set_trace, …). |
Authoring a hook: ship a .pre-commit-hooks.yaml in your repo declaring hook IDs, entry points, and language. Tag a release and reference it from any project's .pre-commit-config.yaml.
Troubleshooting common errors
| Symptom | Cause | Fix |
|---|---|---|
An unexpected error has occurred: CalledProcessError during install | Network issue cloning the hook repo, or hook repo uses a missing language runtime | Check ~/.cache/pre-commit/pre-commit.log; ensure the language (Python, Node, Rust) is installed. |
| Hook runs forever / hangs | Hook waiting on stdin (legacy hook authored to read input) | Add pass_filenames: false or pin to a newer hook version that accepts paths as args. |
| Hook ignored despite being in config | Wrong stages: for the current invocation, or files: regex doesn't match staged file | Run pre-commit run --all-files --verbose to see filtering decisions. |
Cannot install repo ... language not installed | Missing Node/Rust/Go on the system | Install the runtime. For Node hooks, pre-commit can install Node automatically if language: node and language_version: '20' are set. |
pre-commit autoupdate makes no changes | Already on latest tags, or hooks pinned to floating ref | pre-commit autoupdate --freeze pins to SHA for stricter reproducibility. |
| Hook environment stale after upgrade | Cache mismatch | pre-commit clean purges; reruns rebuild from scratch. |
| Pre-commit installs but hooks don't fire | Wrong hook type installed; core.hooksPath set to a different directory | Check cat .git/hooks/pre-commit exists; git config --get core.hooksPath is unset (or pointing to .git/hooks/). |
--no-verify used routinely by contributors | Onboarding/UX issue | Investigate which hook is slow. If a hook needs to be bypassed daily, it's misconfigured (wrong stage, too broad, or genuinely buggy). |
pre-commit run --hook-stage <stage> --all-files --verbose is the diagnostic command — verbose output shows skip reasons per file.
Ecosystem integrations
pre-commit composes well with the rest of the dev-tooling stack:
pre-commit.ci— hosted runner. Reads.pre-commit-config.yaml, runs hooks on every PR, opens auto-update PRs weekly. Free for OSS. Theci:block in the config tunes behaviour.- GitHub Actions — official action:
pre-commit/action@v3. Runspre-commit run --all-fileswith cache support. pre-commit-update— third-party CLI that does targeted version bumps with PR descriptions.lefthook— alternative tool, single-binary Go. Faster startup, smaller plugin world. Use when a Python dep is undesirable.husky+lint-staged— Node ecosystem equivalent. Co-existence in polyglot repos is awkward; pick one.- IDE integration — most editors offer a "format on save" path independent of pre-commit. Pre-commit is the safety net, not the only line of defence.
CI integration
name: pre-commit
on: [push, pull_request]
jobs:
pre-commit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- uses: actions/cache@v4
with:
path: ~/.cache/pre-commit
key: pre-commit-${{ runner.os }}-${{ hashFiles('.pre-commit-config.yaml') }}
- uses: pre-commit/action@v3.0.1
Key choices:
- Cache
~/.cache/pre-commit/— hook environments are expensive to rebuild. Key on the config hash so adding a hook invalidates. pre-commit/action@v3— wrapspre-commit run --all-files --show-diff-on-failure. The diff appears in the failure message so contributors can copy-paste the fix.- Run on
pushandpull_request— push catches direct main commits; PR catches contributor branches. - No matrix — pre-commit's behaviour is identical across Python versions for hook orchestration. Hooks themselves may be version-sensitive (e.g. mypy targeting different Python versions).
For repos with pre-commit.ci enabled, the GitHub Action is redundant — pre-commit.ci runs the same checks and posts results as a PR check. Pick one.
When NOT to use this
- Solo projects on disposable branches. The friction of git-hook setup exceeds the benefit for one-off scripts.
- Trunk-based repos with high commit churn and a tolerant culture. If
--no-verifyis used routinely, pre-commit is failing its purpose; address the hook latency or remove it. - Polyglot repos where
huskyorlefthookalready exists. Don't add a second hook framework. - Hooks that must run in CI but not locally (e.g. heavy security scans). Run those in CI directly, not via pre-commit. pre-commit's value is consistent enforcement; CI-only checks defeat that.
- Repos with strict offline-CI requirements. pre-commit clones hook repos from GitHub on first use. Offline runners need mirroring or
language: systemhooks.
See also
- Python: pre-commit — full
.pre-commit-config.yamlschema, hook recipes, CI integration - Packages: pip-ruff — the most-used pre-commit hook
- Packages: pip-black — runner-up
- Packages: pip-mypy — slower hook, often gated to
stages: [push]