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

bash
pip install pre-commit

Output: (none — exits 0 on success)

bash
uv add --dev pre-commit

Output: dependency added to the dev group in pyproject.toml

bash
poetry add --group dev pre-commit

Output: updated lockfile + dev install

bash
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)

bash
brew install pre-commit

Output: installed via Homebrew on macOS.

After install you wire it into the repo:

bash
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.x shipped through 2023–2024; 2.x through 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:

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

PackageTrade-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 scriptsNo framework, no pinning, no isolation. Don't — they break the moment a contributor's environment differs.
pre-commit.ciHosted SaaS that runs your pre-commit config in CI and auto-updates pinned revs. Complement, not replacement.

Common gotchas

  1. Hooks are pinned to rev: SHAs and drift over time. A six-month-old config silently uses six-month-old ruff rules. Run pre-commit autoupdate periodically (and re-test) to bump every rev: to the latest tag.
  2. pre-commit install vs pre-commit uninstall lifecycle. Cloning a repo doesn't activate hooks — every new clone needs pre-commit install. Add it to your README; consider pre-commit install --hook-type commit-msg for commit-message validators.
  3. Hooks only see staged files by default. A modified-but-unstaged file is ignored. pre-commit run --all-files runs across the whole tree — use this in CI and after pre-commit autoupdate.
  4. 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.yaml like a dependency manifest: review what repos you're pulling.
  5. Cache lives in ~/.cache/pre-commit/. When a hook environment misbehaves after an upgrade, pre-commit clean blows it away. Heavy environments (e.g. mirrors-mypy) can take a minute to rebuild.
  6. CI usage requires a Python interpreter even for non-Python hooks. A Rust-only project using cargo fmt via pre-commit still installs Python in CI. Some teams prefer lefthook to avoid the Python step.
  7. fail_fast: true stops 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.
  8. stages: matters. stages: [commit] (default) runs on git commit. stages: [push] runs on git 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

yaml
# .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 hookslanguage: system invokes commands directly without spinning up an isolated env. Use for tools that are already on PATH (pytest, internal scripts).

Authoring a local hook

yaml
- id: check-no-print
  name: forbid print() in src/
  entry: scripts/check-no-print.sh
  language: script
  files: ^src/.*\.py$
bash
#!/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

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

bash
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: true stops at the first failure. Default is to run every hook — usually better, since one fix often surfaces another. Use fail_fast only 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: and exclude: scope hooks to relevant paths. files: ^src/.*\.py$ runs the hook only on Python files under src/. 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: for language: python hooks. 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 clean purges. 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 hooks pre-commit install wires up. Default is [pre-commit]; add pre-push and commit-msg for hooks that target those stages.
  • exclude: at the top level — repo-wide exclusions (exclude: ^(generated|vendor)/). Override per-hook with files:.
  • 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: weekly keeps 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: commitpre-commit, pushpre-push, merge-commitpre-merge-commit. Old names work as aliases for now but emit a deprecation warning.
  • --show-diff-on-failure is now the default for pre-commit run --all-files in CI environments.

pre-commit 3.x (2023)

  • Python 3.7 dropped.
  • language: python_venv removed; language: python is the canonical form.

Hook rev managementpre-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:

  1. Pin minimum_pre_commit_version: in the config.
  2. Bump pre-commit itself separately from hook bumps. Bumping the framework is rarely disruptive; bumping hook revs frequently is.
  3. After pre-commit autoupdate, run pre-commit run --all-files and 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:

RepoWhat it provides
pre-commit/pre-commit-hooksStdlib hooks: trailing-whitespace, end-of-file-fixer, check-yaml, check-json, debug-statements, check-merge-conflict, …
astral-sh/ruff-pre-commitruff and ruff-format.
psf/black-pre-commit-mirrorblack. Mirror so pre-commit autoupdate can pin to PSF-tagged releases.
pre-commit/mirrors-mypymypy. Mirror repo because mypy itself doesn't tag releases for pre-commit.
gitleaks/gitleaksSecret scanner.
commitizen-tools/commitizenConventional-commits enforcement on the commit-msg stage.
igorshubovych/markdownlint-cliMarkdown linting.
adrienverge/yamllintYAML linting.
hadolint/hadolintDockerfile linting.
pre-commit/pygrep-hooksTiny 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

SymptomCauseFix
An unexpected error has occurred: CalledProcessError during installNetwork issue cloning the hook repo, or hook repo uses a missing language runtimeCheck ~/.cache/pre-commit/pre-commit.log; ensure the language (Python, Node, Rust) is installed.
Hook runs forever / hangsHook 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 configWrong stages: for the current invocation, or files: regex doesn't match staged fileRun pre-commit run --all-files --verbose to see filtering decisions.
Cannot install repo ... language not installedMissing Node/Rust/Go on the systemInstall 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 changesAlready on latest tags, or hooks pinned to floating refpre-commit autoupdate --freeze pins to SHA for stricter reproducibility.
Hook environment stale after upgradeCache mismatchpre-commit clean purges; reruns rebuild from scratch.
Pre-commit installs but hooks don't fireWrong hook type installed; core.hooksPath set to a different directoryCheck cat .git/hooks/pre-commit exists; git config --get core.hooksPath is unset (or pointing to .git/hooks/).
--no-verify used routinely by contributorsOnboarding/UX issueInvestigate 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. The ci: block in the config tunes behaviour.
  • GitHub Actions — official action: pre-commit/action@v3. Runs pre-commit run --all-files with 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

yaml
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 — wraps pre-commit run --all-files --show-diff-on-failure. The diff appears in the failure message so contributors can copy-paste the fix.
  • Run on push and pull_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-verify is used routinely, pre-commit is failing its purpose; address the hook latency or remove it.
  • Polyglot repos where husky or lefthook already 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: system hooks.

See also