pre-commit manages hooks that inspect files before a commit. It catches trailing whitespace, invalid YAML, formatting, and lint issues early, but does not replace tests, review, or CI.
python -m pip install pre-commit
pre-commit install
Create .pre-commit-config.yaml with a small set of trusted hooks. Pin every rev; check current compatible releases in the upstream repositories rather than copying an old version blindly.
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: check-yaml
- id: end-of-file-fixer
- id: trailing-whitespace
Run all files after setup:
pre-commit run --all-files
Some hooks modify files. Review the diff and run them again before committing. The official documentation explains isolated environments and hook configuration.
Add the checks from the Ruff guide and execute the same policy in CI:
pre-commit run --all-files --show-diff-on-failure
pytest
Treat pre-commit autoupdate as a dependency update: read changelogs, run it on a branch, and review changes. Hooks execute third-party code, so use trusted repositories and fixed revisions. Avoid network-dependent or slow hooks on every commit, document installation, and keep CI as the final authority.
Add Ruff without duplicating responsibilities
Each hook should have a clear owner. Let the basic hooks handle file hygiene and let Ruff handle Python linting and formatting. Running several formatters over the same file creates noisy diffs and disagreements that are difficult to diagnose.
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.12.4
hooks:
- id: ruff-check
args: [--fix]
- id: ruff-format
The revision above is an explicit example, not a promise that it is the latest release. Verify the current release and compatibility before adopting it. Put Ruff rules in pyproject.toml, which also allows editors, local commands, and CI to read one policy:
[tool.ruff]
target-version = "py311"
line-length = 88
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B"]
Order matters when one hook changes files another hook reads. Lint fixes should run before formatting. After an automatic correction, the commit is intentionally stopped so you can inspect and stage the new diff. Run the command again; a clean second pass proves the transformations have reached a stable result.
Scope hooks deliberately
By default, pre-commit selects supported files from the staged change. Use files, exclude, and types to avoid generated code, vendored assets, migrations, or fixtures that must preserve exact whitespace. Keep exclusions narrow and explain surprising ones in a comment.
- id: check-yaml
exclude: ^tests/fixtures/invalid/
- id: trailing-whitespace
types: [text]
Do not exclude an entire directory merely because one file fails. First decide whether the failure reveals a real defect. pre-commit run hook-id --all-files --verbose is useful for isolating one hook, while pre-commit run --files path/to/a.py path/to/b.py reproduces selection for specific files.
Use stages only when a check truly belongs to another Git stage. A fast formatter fits pre-commit; a commit-message convention can use commit-msg; an expensive integration suite belongs in CI rather than making every local commit wait.
Existing repositories and team adoption
Installing the hook does not inspect repository history. The first pre-commit run --all-files may therefore change many files. Introduce that cleanup in a dedicated commit, separate from feature work, so later blame and review remain useful. Then add these bootstrap commands to the contributor guide:
python -m pip install -r requirements-dev.txt
pre-commit install --install-hooks
pre-commit run --all-files
Pin pre-commit itself in the development dependency file as well as pinning repository revisions in the configuration. A teammate can temporarily bypass hooks with git commit --no-verify, so bypass is not an enforcement mechanism. It is an escape hatch for an exceptional local situation, and the same checks must still fail the CI job.
For a monorepo, place the configuration at the Git root and use file filters for each package. If commands need a package-specific working directory, prefer a repository script that changes context explicitly. Avoid depending on whichever directory the developer happened to use.
Local hooks and security
A repo: local entry is suitable for a project-owned command:
- repo: local
hooks:
- id: unit-tests-fast
name: fast unit tests
entry: python -m pytest -q tests/unit
language: system
pass_filenames: false
language: system uses the active environment, so it is less isolated than hooks whose environment pre-commit creates. Document that constraint and reserve it for commands whose dependencies the project already manages. If a script can accept filenames, allow pre-commit to pass them; setting pass_filenames: false unnecessarily can turn a quick check into a full-suite run.
Review hook updates like any supply-chain change. The rev identifies code that will execute on developer machines and CI, sometimes with access to repository contents and environment variables. Use official repositories, inspect unexpected ownership changes, and never pass deployment secrets to a general-purpose hook.
Match local checks in CI
CI provides the enforceable result because contributors may not have installed local hooks. A minimal job installs the pinned development dependencies and runs:
python -m pre_commit run --all-files --show-diff-on-failure
The command should run from a clean checkout. If a formatting hook modifies a file, the job fails and displays the diff instead of committing changes. Cache pre-commit environments only with a key that includes the Python version and a hash of .pre-commit-config.yaml; otherwise an obsolete environment may hide an upgrade problem.
Keep tests as a separate CI step. This distinction tells contributors whether they have a style/configuration failure or a behavioral failure. It also permits fast quality checks to run in parallel with the test matrix.
Maintain the configuration
Schedule dependency review rather than leaving revisions frozen forever. Run pre-commit autoupdate, inspect the YAML diff, read release notes for major changes, and execute all files plus the test suite. Commit the update separately when practical.
When a hook fails only in CI, compare Python versions, operating systems, locale, file endings, and the exact configuration revision. Use pre-commit clean to discard cached hook environments during diagnosis, not as a routine step. pre-commit gc removes environments no longer referenced by installed configurations.
A healthy setup stays quick, deterministic, and understandable. Start with defects that have an objective fix, measure the local cost, and move expensive policy checks to CI. That balance makes developers trust the hook instead of reflexively bypassing it.
A practical review checklist
Before merging the configuration, clone the repository into a clean directory and follow the documented installation commands. Confirm that the first full run succeeds twice: the first pass may correct files, while the second must be clean. Make a deliberately invalid YAML file and a Python file with a Ruff violation to prove the expected hooks select them. Remove those temporary changes afterward.
Inspect every external repo, its fixed rev, and the purpose of each hook. Check that generated and fixture exclusions match real paths, that no secret is passed through args, and that local hooks work from the Git root. Compare the local output with CI output on the same commit.
Finally, time an ordinary staged run. If latency becomes annoying, identify the expensive hook with verbose output and narrow its files or move it to CI. Do not solve slowness by silently disabling important checks. Record the maintenance owner and update cadence so pinned revisions remain deliberate rather than forgotten.