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.
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.
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
- You will not also run pre-commit run --all-files in CI: a git hook is advisory, any developer can pass --no-verify or SKIP=hookid, so without a server-side check the whole setup is decorative
- Your CI runners have no network egress or you care about cold-start time: the first run clones every hook repo and builds a toolchain per language, which can take minutes and needs to reach GitHub unless you cache ~/.cache/pre-commit or mirror the repos
- You are a single-language repo already standardised on one fast tool: if ruff check and ruff format cover everything, a five-line .git/hooks/pre-commit or lefthook does the same job with none of the cloning and caching
- Your checks need the whole project: type checkers and test suites only receive the staged filenames, so you set pass_filenames: false and always_run: true, and then every commit pays the full-project cost
- You dislike disk creep: each pinned rev of each hook repo gets its own environment and nothing is pruned until you remember to run pre-commit gc, so long-lived machines accumulate gigabytes of old virtualenvs and node installs
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-pushPlain `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-formatrev 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-failureDuring 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 commentautoupdate 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: truelanguage: 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=alwaysKey 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-commitNothing 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.yamltry-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
| Package | Registry | Pick it when |
|---|---|---|
| lefthook | npm | You want one fast Go binary that runs hooks in parallel and you are happy to install each linter yourself |
| husky | npm | A Node-only repo where hooks are just npm scripts and per-hook environment isolation buys you nothing |
| pre-commit-uv | PyPI | Same tool and same config, but Python hook environments are built by uv, which cuts cold install time noticeably |