cheat sheet

pre-commit

Run linters, formatters, and validators automatically on every git commit. Covers .pre-commit-config.yaml, essential hooks (ruff, mypy, check-yaml), autoupdate, and CI integration.

pre-commit — Git Hooks for Linters and Formatters

What it is

pre-commit is a multi-language framework for managing git hooks, written in Python by Anthony Sottile. It reads a .pre-commit-config.yaml file at the repo root, installs each hook into an isolated environment, and runs them automatically against staged files when you git commit. It removes the "I forgot to run ruff before pushing" class of bugs by making the linter the gate, not a polite suggestion — and it keeps every contributor on the same tool versions because the config pins them.

pre-commit is the framework that runs git hooks. It is not itself a linter. The actual checks come from individual hook repositories like astral-sh/ruff-pre-commit or pre-commit/pre-commit-hooks.

Install

bash
# Recommended (uv is fastest)
uv pip install pre-commit

# Or with pip
pip install pre-commit

# Or with pipx (system-wide CLI, isolated environment)
pipx install pre-commit

# macOS Homebrew
brew install pre-commit

Output: (none — exits 0 on success)

Verify the install:

bash
pre-commit --version

Output:

text
pre-commit 3.7.1

Syntax

The base invocation has two phases. First, pre-commit install writes a small shim into .git/hooks/pre-commit that delegates to the pre-commit CLI. After that, git commit automatically runs the configured hooks against staged files; you only invoke pre-commit directly when you want to run hooks on demand.

bash
pre-commit install                       # one-time setup per clone
pre-commit run [HOOK_ID] [--all-files]   # manual invocation
pre-commit autoupdate                    # bump hook versions in the config

Output: (none — exits 0 on success)

Essential options

OptionMeaning
pre-commit installInstall the git hook shim into .git/hooks/
pre-commit install --hook-type pre-pushInstall a different hook type (pre-push, commit-msg, etc.)
pre-commit runRun all hooks against currently staged files
pre-commit run --all-filesRun all hooks against every file in the repo
pre-commit run <hook-id>Run a single hook by its id:
pre-commit run --files file1 file2Run against a specific list of files
pre-commit autoupdateBump rev: for every repo to the latest tag
pre-commit cleanWipe cached hook environments (~/.cache/pre-commit/)
pre-commit uninstallRemove the git hook shim
--show-diff-on-failurePrint the modifying diff when a hook fails
--verboseShow full output even for passing hooks

First-run setup

Initialise pre-commit in a brand-new project. pre-commit sample-config prints a minimal starter config; redirect it to disk and tailor from there. Then pre-commit install wires the hook into the git repo so it runs on every commit.

bash
cd /path/to/repo
pre-commit sample-config > .pre-commit-config.yaml
pre-commit install

Output:

text
pre-commit installed at .git/hooks/pre-commit

The generated .pre-commit-config.yaml looks like this:

yaml
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

pre-commit install only writes to the current clone's .git/hooks/. Each new clone of the repo needs to run it once, or use core.hooksPath plus an opt-in repo script to remind contributors.

Anatomy of .pre-commit-config.yaml

The config file lists one entry per hook repository. Each repo: block pins a specific rev: (a git tag) and enables one or more named hooks:. Pinning the rev guarantees every contributor and CI runner gets the same checker version — exactly the reason you wanted automation in the first place.

yaml
# .pre-commit-config.yaml

# Optional global defaults
default_language_version:
  python: python3.12

# Files matched by *all* hooks unless a hook overrides
exclude: '^(migrations/|vendor/|\.venv/)'

repos:
  # 1. Built-in utility hooks — fast, no Python dependencies
  - 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-toml
      - id: check-json
      - id: check-merge-conflict
      - id: check-added-large-files
        args: ["--maxkb=500"]

  # 2. Ruff — replaces flake8 / isort / pyupgrade
  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.4.4
    hooks:
      - id: ruff
        args: ["--fix"]
      - id: ruff-format

  # 3. mypy — strict typing on src/
  - repo: https://github.com/pre-commit/mirrors-mypy
    rev: v1.10.0
    hooks:
      - id: mypy
        files: ^src/
        additional_dependencies: ["pydantic>=2", "types-requests"]

Output: (none — config file only)

Key fields per hook:

FieldPurpose
idName as declared in the hook repo's .pre-commit-hooks.yaml
argsExtra CLI flags passed to the underlying tool
filesRegex restricting which files trigger this hook
excludeRegex of files to skip for this hook only
language_versionPin the interpreter (e.g. python3.12)
additional_dependenciesExtra packages installed into the hook's virtualenv
stagesWhen to run — commit (default), push, manual
pass_filenames: falseDon't pass file list to the tool (for project-wide tools)

Essential hook reference

A short catalogue of the hooks that earn their keep on almost every Python repo. Each one solves a real problem you'll otherwise re-discover during a code review.

pre-commit-hooks (the standard library of hooks)

The pre-commit/pre-commit-hooks repo is the official collection of fast generic checks — no language-specific dependencies, all written for speed. Reach for it first; only fall back to specialised hooks when these don't cover a need.

yaml
- repo: https://github.com/pre-commit/pre-commit-hooks
  rev: v4.6.0
  hooks:
    - id: trailing-whitespace          # strip trailing spaces
    - id: end-of-file-fixer            # ensure files end with a newline
    - id: check-yaml                   # parse every .yaml/.yml
    - id: check-toml                   # parse every .toml
    - id: check-json                   # parse every .json
    - id: check-added-large-files      # block files > 500 KB by default
      args: ["--maxkb=500"]
    - id: check-merge-conflict         # block <<<<<<< markers
    - id: check-case-conflict          # block files differing only in case
    - id: detect-private-key           # block accidental private keys
    - id: mixed-line-ending            # normalise CRLF/LF
      args: ["--fix=lf"]
    - id: check-executables-have-shebangs
    - id: debug-statements             # block `breakpoint()` / `pdb.set_trace()`

Output: (none — config block)

ruff and ruff-format

Ruff is the modern Python linter + formatter (covered in sections/python/ruff). The pre-commit integration runs both checks; with --fix the linter auto-repairs what it can, and ruff-format reformats files like Black does.

yaml
- repo: https://github.com/astral-sh/ruff-pre-commit
  rev: v0.4.4
  hooks:
    - id: ruff
      args: ["--fix"]            # auto-fix where safe
    - id: ruff-format            # Black-compatible formatter

Output: (none — config block)

Put ruff before ruff-format in the list. The linter may reorder imports or remove unused ones; the formatter then makes the result canonical. Reversing the order can produce a double-pass diff.

mypy

Strict typing in pre-commit is harder than ruff because mypy needs the actual third-party packages installed (it can't type-check imports it doesn't see). Use additional_dependencies: to install the type stubs and runtime libraries the hook environment needs.

yaml
- repo: https://github.com/pre-commit/mirrors-mypy
  rev: v1.10.0
  hooks:
    - id: mypy
      files: ^src/
      additional_dependencies:
        - "pydantic>=2"
        - "types-requests"
        - "types-PyYAML"
      args: ["--strict", "--ignore-missing-imports"]

Output: (none — config block)

black (when not using ruff-format)

Some teams still prefer Black. The pre-commit integration is straightforward and version-pins Black to a specific tag.

yaml
- repo: https://github.com/psf/black
  rev: 24.4.2
  hooks:
    - id: black
      args: ["--line-length=100"]

Output: (none — config block)

prettier (for JS/TS/YAML/MD in the same repo)

Many Python projects also carry frontend code, YAML, JSON, or Markdown. The mirror repo runs prettier in a Node sandbox; pre-commit handles the install for you.

yaml
- repo: https://github.com/pre-commit/mirrors-prettier
  rev: v3.1.0
  hooks:
    - id: prettier
      types_or: [yaml, json, markdown]

Output: (none — config block)

pyupgrade, bandit, nbstripout

A few more situational hooks. pyupgrade rewrites old syntax to the minimum Python version you support; bandit flags common security anti-patterns; nbstripout clears output cells from Jupyter notebooks before they hit git.

yaml
- repo: https://github.com/asottile/pyupgrade
  rev: v3.15.2
  hooks:
    - id: pyupgrade
      args: ["--py312-plus"]

- repo: https://github.com/PyCQA/bandit
  rev: 1.7.8
  hooks:
    - id: bandit
      args: ["-c", "pyproject.toml"]
      additional_dependencies: ["bandit[toml]"]

- repo: https://github.com/kynan/nbstripout
  rev: 0.7.1
  hooks:
    - id: nbstripout

Output: (none — config block)

Running hooks on demand

pre-commit run defaults to running every configured hook against staged files only — the same set git would commit. Pass --all-files to sweep the entire repository (this is the right move just after adding a new hook). Pass a hook id to run just one.

bash
# Run every hook against staged files (what `git commit` does)
pre-commit run

# Run every hook against the entire repo
pre-commit run --all-files

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

# Run against a specific file list
pre-commit run --files src/api.py src/utils.py

Output:

text
trim trailing whitespace.................................................Passed
fix end of files.........................................................Passed
check yaml...............................................................Passed
check for added large files..............................................Passed
ruff.....................................................................Passed
ruff-format..............................................................Passed
mypy.....................................................................Passed

When a hook fails it returns non-zero, prints the offending files, and (for auto-fixers) leaves the modified files unstaged. You then git add the fixes and commit again.

text
ruff-format..............................................................Failed
- hook id: ruff-format
- files were modified by this hook

1 file reformatted, 4 files left unchanged

If a hook modifies a file, the commit aborts. Re-stage the file with git add and run git commit again. This is by design — auto-fixed code should be reviewed before it enters history.

Updating hook versions

pre-commit autoupdate bumps every rev: to the latest tag from each hook's git repository. Run it monthly, review the diff, and commit. The --bleeding-edge flag follows the default branch instead of tags (useful for hooks without releases).

bash
pre-commit autoupdate

Output:

text
[https://github.com/pre-commit/pre-commit-hooks] updating v4.6.0 -> v4.7.1
[https://github.com/astral-sh/ruff-pre-commit] updating v0.4.4 -> v0.5.2
[https://github.com/pre-commit/mirrors-mypy] updating v1.10.0 -> v1.11.0
bash
# Only bump specific repos
pre-commit autoupdate --repo https://github.com/astral-sh/ruff-pre-commit

Output:

text
[https://github.com/astral-sh/ruff-pre-commit] updating v0.4.4 -> v0.5.2

After autoupdate, run pre-commit run --all-files once. New rules in a newer linter version will flag previously-passing code; surfacing those changes alongside the version bump keeps history clean.

Skipping and bypassing

Sometimes a hook is wrong, slow, or you genuinely need an emergency commit. Pre-commit offers four escape hatches; use them in order of preference.

bash
# 1. Skip one or more hooks for a single commit (preferred — auditable)
SKIP=mypy,ruff git commit -m "wip: refactor"

# 2. Mark a hook as "manual" in config so it only runs when invoked explicitly
#    stages: [manual]

# 3. Bypass all hooks for a single commit (last resort)
git commit --no-verify -m "emergency hotfix"

# 4. Globally disable
pre-commit uninstall

Output: (none — commit completes as usual)

Most teams accept SKIP= but ban --no-verify in PR review. The SKIP variable is visible in the commit metadata-by-convention; --no-verify leaves no trace.

Stages — pre-commit, pre-push, commit-msg

By default hooks run at the commit stage. Pre-commit also supports push, commit-msg, prepare-commit-msg, post-checkout, post-merge, and post-rewrite. Use pre-push for slow checks (full test suite, type-check the whole package) so day-to-day commits stay snappy.

yaml
- repo: https://github.com/pre-commit/mirrors-mypy
  rev: v1.10.0
  hooks:
    - id: mypy
      stages: [pre-push]            # only run on push, not every commit
      args: ["--strict"]

Install the corresponding hook type:

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

Output:

text
pre-commit installed at .git/hooks/pre-push
pre-commit installed at .git/hooks/commit-msg

For commit-message linting (e.g. conventional commits) the commitizen and commitlint hooks are popular:

yaml
- repo: https://github.com/commitizen-tools/commitizen
  rev: v3.27.0
  hooks:
    - id: commitizen
      stages: [commit-msg]

Local hooks (no upstream repo)

A repo: local block runs a command from inside your own project — useful when you want to call a script in the repo, or run a tool you don't want to fetch from a remote. The command must be on PATH (or installed via additional_dependencies).

yaml
- repo: local
  hooks:
    - id: pytest-fast
      name: pytest (fast tests only)
      entry: pytest -m "not slow" -x -q
      language: system
      types: [python]
      pass_filenames: false

    - id: validate-frontmatter
      name: validate article frontmatter
      entry: python scripts/validate_frontmatter.py
      language: system
      files: ^src/content/.*\.md$
      pass_filenames: false

Output: (none — config block)

Local hooks share four mandatory fields: id, name, entry, and language. language: system means "use whatever's already installed"; alternatives include python, node, docker, and script (run a file from the repo).

CI integration

A pre-commit hook can be skipped locally; CI is where you enforce. Two patterns are common — pre-commit.ci (the hosted service) and a self-managed GitHub Actions step.

pre-commit.ci (free hosted CI)

pre-commit.ci is a free service that runs hooks on every PR, auto-fixes what it can, and opens follow-up commits to bump hook versions weekly. Add it under the project root config:

yaml
# .pre-commit-config.yaml
ci:
  autofix_commit_msg: "style: auto-fix from pre-commit hooks"
  autoupdate_schedule: weekly
  skip: [mypy]            # skip slow / network-dependent hooks in CI

Install the GitHub App at https://pre-commit.ci/ and grant access to the repo. No additional workflow file required.

GitHub Actions

For full control (private services, custom secrets) run pre-commit directly in CI:

yaml
# .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"
          cache: pip
      - run: pip install pre-commit
      - uses: actions/cache@v4
        with:
          path: ~/.cache/pre-commit
          key: pre-commit-${{ hashFiles('.pre-commit-config.yaml') }}
      - run: pre-commit run --all-files --show-diff-on-failure

Output: (none — workflow file)

The cache step is the most important line — without it, every CI run reinstalls every hook environment from scratch (multi-minute slowdown).

Common pitfalls

  1. Forgetting pre-commit install after clone — the config exists but no hooks run. Fix: document this in README.md, or use a Makefile/justfile target like make setup.
  2. mypy can't import third-party packages — by default the mypy hook runs in an isolated venv. Add the imports under additional_dependencies: or set language: system with pre-commit install --hook-type pre-push so it uses your project venv.
  3. autoupdate introduces new lint rules — a newer ruff version surfaces previously-passing code as errors. Always run pre-commit run --all-files after autoupdate before committing.
  4. Hook order matters — put auto-fixers (ruff --fix, ruff-format, end-of-file-fixer) before strict checkers (mypy). A broken file passing through a formatter first reduces unrelated noise.
  5. --no-verify hides everything — if a hook genuinely is broken, fix the hook (or use SKIP=<id> for that commit) instead of bypassing the whole hook chain.
  6. Large repos crawl — pass --files or rely on staged-only mode rather than --all-files. The first --all-files run can take minutes; subsequent staged runs are seconds.
  7. check-added-large-files default is 500 KB — fine for code, breaks notebooks and ML projects. Raise it (args: ["--maxkb=5000"]) or exclude data directories.
  8. Hooks not running in CI — your .pre-commit-config.yaml must be committed and either pre-commit.ci is installed or your workflow runs pre-commit run --all-files. Just having the file does nothing on its own.
  9. Stale hook cachepre-commit clean wipes ~/.cache/pre-commit/. Use after corrupting an environment (rare, but happens with interrupted installs).
  10. YAML indentation.pre-commit-config.yaml is YAML; one missing space breaks the file. check-yaml won't catch errors in itself. Use an editor with YAML schema support (the schema URL is in pre-commit's docs).

Real-world recipes

Bootstrap a new Python project

Spin up pre-commit for a fresh project with ruff, mypy, and the standard utility hooks. Three commands and one config file.

bash
cd ~/code/myproject
pip install pre-commit
cat > .pre-commit-config.yaml <<'EOF'
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-toml
      - id: check-added-large-files
        args: ["--maxkb=500"]
      - id: check-merge-conflict
      - id: detect-private-key
  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.4.4
    hooks:
      - id: ruff
        args: ["--fix"]
      - id: ruff-format
  - repo: https://github.com/pre-commit/mirrors-mypy
    rev: v1.10.0
    hooks:
      - id: mypy
        files: ^src/
        additional_dependencies: ["pydantic>=2"]
EOF
pre-commit install
pre-commit run --all-files

Output:

text
trim trailing whitespace.................................................Passed
fix end of files.........................................................Passed
check yaml...............................................................Passed
check toml...............................................................Passed
check for added large files..............................................Passed
check for merge conflicts................................................Passed
detect private key.......................................................Passed
ruff.....................................................................Passed
ruff-format..............................................................Passed
mypy.....................................................................Passed

Adopt pre-commit on a legacy codebase

Adding strict tooling to an old project breaks the first commit catastrophically. The trick is to land the auto-fixes as one cleanup commit, then enforce going forward.

bash
# Step 1: install pre-commit but disable strict hooks
pre-commit install

# Step 2: run only the formatters first to bulk-format the repo
pre-commit run ruff-format --all-files
pre-commit run end-of-file-fixer --all-files
git add -A
git commit -m "style: apply pre-commit auto-formatters across repo"

# Step 3: now enable strict checks (mypy, ruff lint) one rule set at a time
# Edit .pre-commit-config.yaml, add --select for ruff
pre-commit run ruff --all-files
# Fix or silence with # noqa per file; commit the fixes
git add -A
git commit -m "lint: clean up F401 / E501 / B008 violations"

# Step 4: turn on mypy with files: regex limited to one module
# Repeat until every module is type-checked

Output:

text
[main 1a2b3c4] style: apply pre-commit auto-formatters across repo
 47 files changed, 312 insertions(+), 198 deletions(-)

Run slow checks only on push

Type-checking a large project at every commit kills the dev loop. Move mypy and the test suite to pre-push so commits stay sub-second.

yaml
# .pre-commit-config.yaml
repos:
  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.4.4
    hooks:
      - id: ruff
      - id: ruff-format
  - repo: https://github.com/pre-commit/mirrors-mypy
    rev: v1.10.0
    hooks:
      - id: mypy
        stages: [pre-push]
        files: ^src/
  - repo: local
    hooks:
      - id: pytest-quick
        name: pytest (quick subset)
        entry: pytest -m "not slow" -q
        language: system
        stages: [pre-push]
        pass_filenames: false
bash
pre-commit install --hook-type pre-commit --hook-type pre-push

Output:

text
pre-commit installed at .git/hooks/pre-commit
pre-commit installed at .git/hooks/pre-push

Block commits that contain leftover debug code

A local hook plus a shell script flags print( calls in non-test code — a common accidental commit. Combine with debug-statements (which catches breakpoint() and pdb.set_trace()).

yaml
- repo: local
  hooks:
    - id: no-print
      name: block accidental print() statements
      entry: bash -c 'if grep -nE "^[^#]*\bprint\(" "$@" | grep -v "# noqa: print"; then exit 1; fi' --
      language: system
      files: ^src/.*\.py$
      exclude: ^src/cli/
bash
git add src/api/handler.py
git commit -m "fix: api handler"

Output (when a stray print sneaks in):

text
block accidental print() statements.....................................Failed
- hook id: no-print
- exit code: 1

src/api/handler.py:47:    print("DEBUG:", payload)

Lint a multi-language repo (Python + JS + YAML + Markdown)

Pre-commit's strength is handling more than one ecosystem in one config. Bring in prettier for the frontend and Markdown side, ruff for Python, and the generic checkers for YAML and JSON.

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

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

  - repo: https://github.com/pre-commit/mirrors-prettier
    rev: v3.1.0
    hooks:
      - id: prettier
        types_or: [yaml, markdown, json, javascript, typescript]
        exclude: ^(package-lock\.json|pnpm-lock\.yaml)

Output: (none — config block)

Verify hooks pass before opening a PR

A quick sanity check that mirrors what CI will do. Useful as a make ci target.

bash
pre-commit run --all-files --show-diff-on-failure

Output:

text
trim trailing whitespace.................................................Passed
fix end of files.........................................................Passed
check yaml...............................................................Passed
ruff.....................................................................Passed
ruff-format..............................................................Passed
mypy.....................................................................Passed

A non-zero exit means CI will fail; the diff is printed inline so you can inspect what needs fixing.

See also

  • sections/python/ruff — the linter most commonly wired into pre-commit.
  • sections/python/black — alternative formatter; same pre-commit pattern.
  • sections/python/mypy — static type checker; additional_dependencies: patterns covered there.
  • sections/python/pyproject-toml — most pre-commit hooks read their config from [tool.<name>] tables.
  • sections/python/pytest — pair with a pre-push stage for a fast test subset.