mrkeyoor.com_
Thu 06 Aug 02:40 UTC
PyPICLI & Toolingupdated 06 Aug 2026

pre-commit

pre-commit is a git hook manager. You list the checks you want in a .pre-commit-config.yaml file, run pre-commit install once, and from then on git runs those checks against your staged files before each commit. What separates it from a shell script in .git/hooks is environment management: every hook lives in a git repo that declares the language it is written in, and pre-commit clones that repo at a pinned revision and builds an isolated environment for it under ~/.cache/pre-commit. That means a Python repo can run a Node formatter, a Go linter, and a Docker-based checker without any developer installing Node, Go, or those tools globally. Twenty-odd language backends are supported, including python, node, golang, rust, ruby, docker, and a dependency-free pygrep matcher.

Verdict

For any repo with more than one language, pre-commit remains the default and the pinned-rev config is worth the cold-start cost. Treat the local hook as a convenience and run pre-commit run --all-files in CI, because that is the only place the checks are actually enforced.

API stability5/5The config format has stayed backward compatible for years; the 4.0 stage renames from commit to pre-commit still work with a warning, and pre-commit migrate-config rewrites old files for you rather than breaking them.
Docs4/5pre-commit.com documents every config key, all the supported languages, and how to author your own hooks, but the answer to why a hook did not run is spread across the filtering, stages, and caching pages instead of collected into one troubleshooting page.
Maintenance5/54.6.1 shipped 21 July 2026 with the repo pushed the same day, and there are only 19 open issues (27 counting PRs) after twelve years; the flip side is that almost all of that work comes from a single maintainer, so the bus factor is one.
Ecosystem5/5About 42M weekly downloads and 15k stars, a public hooks index on pre-commit.com, and a .pre-commit-hooks.yaml shipped by most Python linters plus many JS and shell tools; pre-commit.ci runs the identical config as a hosted GitHub app.

Use it if

  • Your repo mixes languages and you want one file that runs a Python formatter, a Node linter, and a shell checker without every developer installing those toolchains by hand
  • You want the exact same checks locally and in CI from a single config, so that a green local commit actually predicts a green pipeline
  • You want hooks to run only on staged files by default, which keeps commit-time checks fast on a repo with tens of thousands of files
  • You want tool versions pinned in git via the rev field, with pre-commit autoupdate as a deliberate reviewed bump instead of silent version drift
  • You are already using a hook published as a .pre-commit-hooks.yaml repo, which by now is most Python and many JavaScript linters
Skip it if

Setup reality

pip install pre-commit needs Python 3.10 or newer and pulls virtualenv, nodeenv, identify, cfgv, and pyyaml. Write .pre-commit-config.yaml, then run pre-commit install to write .git/hooks/pre-commit. The first commit after that is the slow one, because pre-commit clones each hook repo at its pinned rev and builds an environment per language; a config with one Node formatter downloads an entire Node toolchain into ~/.cache/pre-commit. Three things reliably trip teams up. pre-commit install only wires the pre-commit hook type, so anything with stages: [commit-msg] or [pre-push] silently never fires until you add default_install_hook_types or pass --hook-type. Hook environments are isolated from your project virtualenv, so a mypy hook sees none of your dependencies and floods you with missing-import errors until you list stubs under additional_dependencies. And Docker-based hooks require a working daemon on every developer machine and every CI runner, which is usually where a config stops being portable.

Patterns

Install the git hookinstall-hooks

pip install pre-commit
pre-commit install

# also wire the other hook types you use:
pre-commit install --hook-type commit-msg --hook-type pre-push

Plain `pre-commit install` writes only .git/hooks/pre-commit. Any hook declaring stages: [pre-push] or [commit-msg] will never fire until you install that hook type, and pre-commit does not warn you. This is the single most common cause of a hook that appears to do nothing.

A starting .pre-commit-config.yamlminimal-config

repos:
  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v6.0.0
    hooks:
      - id: trailing-whitespace
      - id: end-of-file-fixer
      - id: check-yaml
      - id: check-added-large-files

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

rev must be an immutable ref, not a branch: pre-commit caches an environment per rev and pointing at main means every push silently changes what your team runs. The ruff hook id is now ruff-check; the older id ruff still resolves as a legacy alias.

Run hooks outside of a commitrun-all-files

pre-commit run --all-files                 # everything, every hook
pre-commit run ruff-format --all-files     # one hook
pre-commit run --files src/a.py src/b.py   # explicit file list
pre-commit run --all-files --show-diff-on-failure

During a commit hooks only see staged files, so adding a formatter to an existing repo leaves the rest of the tree unformatted. Run this once, commit the reformat churn on its own so it does not pollute review diffs, and the hook stays quiet afterwards.

Bump hook versionsupdate-pinned-revs

pre-commit autoupdate
pre-commit autoupdate --repo https://github.com/astral-sh/ruff-pre-commit
pre-commit autoupdate --freeze   # store the sha, keep the tag as a comment

autoupdate rewrites rev to each repo's latest tag but does not run anything, so always follow it with `pre-commit run --all-files` before committing; a new formatter release will otherwise reformat half your repo inside someone else's feature branch. Use --freeze if you do not trust upstream tags to stay immutable.

Bypass hooks for one commitskip-a-hook

SKIP=mypy,pytest git commit -m 'wip'
git commit --no-verify -m 'emergency revert'

SKIP takes a comma-separated list of hook ids and is the polite escape hatch. --no-verify disables every hook including commit-msg. Both are always available to developers, which is exactly why the same config has to run in CI.

Define hooks that live in your repolocal-hook

repos:
  - repo: local
    hooks:
      - id: no-bare-print
        name: no bare print() in app code
        language: pygrep
        entry: '^\s*print\('
        files: ^app/.*\.py$

      - id: unit-tests
        name: pytest
        language: system
        entry: pytest -q
        pass_filenames: false
        always_run: true

language: pygrep treats entry as a Python regex and fails when it matches, with no environment to build, so it is the cheapest way to ban a pattern. language: system runs whatever is already on PATH, which means pre-commit stops pinning the version for you and the hook behaves differently on a machine with a different pytest.

Give a hook the packages it needshook-dependencies

  - repo: https://github.com/pre-commit/mirrors-mypy
    rev: v2.3.0
    hooks:
      - id: mypy
        additional_dependencies:
          - types-requests
          - pydantic>=2
        args: [--strict, --ignore-missing-imports]

The hook environment is completely isolated from your project virtualenv, so mypy cannot see any of your dependencies and reports import errors for all of them. Every stub or runtime package the checker needs has to be repeated here, and it drifts out of sync with your real requirements file. This is the top source of hook results that disagree with running the tool in your terminal.

Control which files a hook receivesfilter-files

  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v6.0.0
    hooks:
      - id: end-of-file-fixer
        exclude: ^(vendor/|third_party/|.*\.snap$)
      - id: check-json
        types: [json]
        exclude_types: [jupyter]

files and exclude are unanchored Python regexes matched against the repo-relative path, so `files: config` also matches src/reconfigure.py; anchor with ^ unless you mean it. types uses the identify library's tags rather than extensions, which is what correctly catches a shell script with no extension but a bash shebang.

Run slow checks at push time instead of commit timemultiple-stages

default_install_hook_types: [pre-commit, commit-msg, pre-push]

repos:
  - repo: local
    hooks:
      - id: full-test-suite
        name: pytest (full)
        language: system
        entry: pytest
        pass_filenames: false
        always_run: true
        stages: [pre-push]

default_install_hook_types means a teammate running plain `pre-commit install` gets all three hook types. Note the 4.0 rename: the stage names are pre-commit, pre-push, and pre-merge-commit now; the old commit, push, and merge-commit still work but print a deprecation warning, and `pre-commit migrate-config` rewrites them.

Run the same config in GitHub Actionsrun-in-ci

- 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-${{ hashFiles('.pre-commit-config.yaml') }}
- run: pip install pre-commit
- run: pre-commit run --all-files --show-diff-on-failure --color=always

Key the cache on the config file, since a cold run rebuilds every hook environment and can dominate the job. --show-diff-on-failure prints the patch the formatters applied, which turns an unhelpful red X into a copy-pasteable fix.

Reclaim or reset the hook cacheclean-cache

pre-commit gc      # drop environments for revs no config references any more
pre-commit clean   # delete the whole cache, forcing a full rebuild
du -sh ~/.cache/pre-commit

Nothing prunes automatically, so a machine that has been through a year of autoupdate runs holds an environment for every old rev. Reach for clean rather than gc when an environment is broken after a system Python or Node upgrade.

Test a hook repo before tagging itdevelop-a-hook

pre-commit try-repo ../my-hooks --all-files --verbose
pre-commit try-repo ../my-hooks my-hook-id --all-files
pre-commit validate-manifest ../my-hooks/.pre-commit-hooks.yaml

try-repo runs hooks straight from a local checkout, which avoids the tag-push-autoupdate loop while you are iterating on a .pre-commit-hooks.yaml. --verbose is worth it because hook stdout is hidden on success.

Alternatives

PackageRegistryPick it when
lefthooknpmYou want one fast Go binary that runs hooks in parallel and you are happy to install each linter yourself
huskynpmA Node-only repo where hooks are just npm scripts and per-hook environment isolation buys you nothing
pre-commit-uvPyPISame tool and same config, but Python hook environments are built by uv, which cuts cold install time noticeably