A command-line tool is only as trustworthy as the code behind it. When users pip install your CLI and run it in their own pipelines, a regression you never caught locally becomes a broken build on someone else's machine. Pre-commit hooks close that gap: they run your linter, formatter, type checker, and tests automatically before any commit lands, so the messy work-in-progress never reaches your history. This overview explains what pre-commit is, why CLI projects benefit from it in particular, and how the individual quality gates fit together.
What pre-commit actually is
pre-commit is a framework — a small Python application — for managing Git hooks. You declare the checks you want in a .pre-commit-config.yaml file at the repo root, and pre-commit installs a Git pre-commit hook that runs them against your staged files every time you git commit. Crucially, it isolates each tool in its own managed environment, so contributors don't need to install ruff, mypy, or black globally. Clone the repo, run one install command, and everyone runs the exact same versions of the exact same checks.
This matters more for CLI projects than for, say, a one-off script. A CLI is software other people execute. It has an entry point, argument parsing, exit codes, and usually a published package on PyPI. Each of those is a surface where a small mistake — an unhandled None, a misformatted help string, a type error in a Typer callback — ships straight to users. Catching it at commit time is the cheapest possible place to catch it.
The gate philosophy
Think of pre-commit as a stack of fast, ordered gates. Each gate has one job, and code only proceeds if it passes all of them. For a Python CLI the four gates that earn their keep are:
- Ruff (lint) — catches unused imports, undefined names, mutable default arguments, and hundreds of other bugs in milliseconds. It replaces flake8, isort, pyupgrade, and several plugins in a single fast binary.
- Ruff (format) — applies a deterministic, black-compatible code style so diffs stay about logic, not whitespace. Running format and lint from the same tool keeps their rules from fighting each other.
- mypy — verifies your type hints actually hold. For CLIs this is where you catch the
str | Noneyou forgot to guard before passing it toPath(). - pytest — runs your test suite as a final gate. A CLI's behavior (exit codes, stdout, error messages) is best pinned with tests, so a green suite before commit is a strong signal.
The ordering is deliberate: cheap, auto-fixing checks first (format, lint), then static analysis (mypy), then the slower behavioral check (pytest) last. If formatting alone fails, you don't waste time running the whole test suite.
Local hooks vs CI hooks
There are two places these gates run, and you want both. Local hooks fire on your machine at commit time. They're fast and give instant feedback, but they're easy to bypass — anyone can git commit --no-verify. CI hooks run the same .pre-commit-config.yaml in your continuous-integration pipeline, where bypassing isn't possible. The single source of truth is the config file, so the local and CI runs check identical things:
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-python@v6
with:
python-version: "3.12"
- run: pip install pre-commit
- run: pre-commit run --all-files --show-diff-on-failure
Local hooks make the right thing convenient; CI hooks make it mandatory. Use local hooks for fast feedback and CI as the enforcement backstop.
A note on versions and reproducibility
Pre-commit pins each hook to a specific revision (a Git tag or commit), which is what makes runs reproducible across machines and across time. You upgrade those pins deliberately with pre-commit autoupdate rather than drifting silently. This pairs naturally with how you manage the rest of your toolchain — see uv for Python CLI dependency management for keeping the dev dependencies that back these hooks locked and reproducible too.
Where to go next
The step-by-step companion to this page walks through every command and the complete config, from a clean clone to a green CI run:
- Setting up pre-commit for Python CLI repos — install pre-commit, write a full
.pre-commit-config.yamlwith ruff, mypy, and a local pytest hook, wire up the matchingpyproject.tomlconfig, and run it in CI.
A configuration that holds up
A pre-commit setup that people keep is short, fast and pinned. Here is one that covers a typical Python CLI repository:
# .pre-commit-config.yaml
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.6.9
hooks:
- id: ruff-format
- id: ruff
args: [--fix, --exit-non-zero-on-fix]
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: end-of-file-fixer
- id: trailing-whitespace
- id: check-added-large-files
args: [--maxkb=512]
- id: check-toml
- id: check-merge-conflict
- id: debug-statements
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.11.2
hooks:
- id: mypy
additional_dependencies: [types-requests]
stages: [manual] # too slow for every commit; run it in CI
Four decisions are encoded there. Formatting runs before linting, so the linter sees the final
text. --exit-non-zero-on-fix makes a hook that rewrote a file fail, which is the behaviour you
want — the commit stops, you look at the change, you commit again. check-added-large-files is
the cheapest possible guard against the accidental data dump. And type checking is present but
staged as manual, because it is the hook most likely to make people start passing -n.
Install it once per clone:
uv tool install pre-commit # or pipx install pre-commit
pre-commit install # writes .git/hooks/pre-commit
pre-commit run --all-files # the first run, over everything
That first full run is worth doing deliberately, on its own commit. It will reformat files across the repository, and you want that as one reviewable change rather than mixed into a feature branch.
Configure tools once, not twice
The most common way a hook setup drifts is having the tool configured in two places. Keep the
behaviour in pyproject.toml, and let the hook file say only which checks run:
[tool.ruff]
target-version = "py311"
line-length = 100
src = ["src", "tests"]
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "SIM"]
ignore = ["E501"] # the formatter owns line length
[tool.mypy]
python_version = "3.11"
strict = true
files = ["src"]
Now a developer running ruff check . by hand, the pre-commit hook, and the CI job all read the
same settings. When they are split — some flags in args:, some in pyproject.toml — you get
the situation where a file passes locally and fails in review, and nobody can see why from the
diff.
The one legitimate use for args: is selecting what a hook looks at, not how it behaves:
restricting a hook to a directory, or passing --fix locally while CI runs the check-only form.
Local hooks and the CI gate
Local hooks are a convenience. They can be skipped with git commit -n, they are not installed
in a fresh clone until somebody runs pre-commit install, and they only ever see staged files.
That is fine — their job is to catch the mistake you just made, quickly.
The gate is the CI job:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- uses: pre-commit/action@v3.0.1
Two properties make it a real gate. It runs over every file, so drift in code nobody has
touched recently is caught. And it cannot be skipped, so nothing depends on every contributor
having installed the hooks. Caching the hook environments keys on the config file, so the job is
fast unless a rev moved.
If you only do one of the two, do the CI job. A repository with CI and no local hooks is consistent and slightly annoying; a repository with local hooks and no CI is inconsistent and believes it is not.
Keeping the set from rotting
Two maintenance habits, both cheap.
Pin every rev to an exact tag. A hook tracking a branch changes under you: a formatter
release reformats files, a linter release adds a rule, and a branch that was green yesterday is
red today with no commit to blame. Pins turn that into a deliberate update.
Update on purpose. pre-commit autoupdate rewrites the revisions to the latest tags. Run it
on its own commit, then pre-commit run --all-files, and review the resulting diff as a unit. A
new formatter version touching two hundred files is fine when that is the whole commit and
alarming when it is buried in a feature.
Beyond that, resist growth. Every hook is time charged to every commit, and the marginal check that fires once a year is better placed in CI. A good pre-commit configuration is one people forget is there, and the way to earn that is to keep it under a couple of seconds.
Hooks worth adding for a CLI specifically
A few checks matter more for a command-line tool than for a library, because they protect the things users interact with.
The entry point still resolves. A renamed module or a typo in [project.scripts] is invisible
until someone installs the wheel. A local hook is a good place for the cheap version of that
check:
- repo: local
hooks:
- id: entry-point-imports
name: entry point target imports
entry: python -c "import importlib; importlib.import_module('mytool.cli')"
language: system
pass_filenames: false
files: ^(src/mytool/|pyproject\.toml)
It runs only when the package or the metadata changed, takes a few milliseconds, and catches the class of mistake that otherwise ships.
The lockfile matches pyproject.toml. Someone adds a dependency, forgets to re-lock, and CI
installs a different graph from the one they tested. Both managers have a one-line check:
- id: lock-is-current
name: lockfile matches pyproject
entry: uv lock --check # or: poetry check --lock
language: system
pass_filenames: false
files: ^(pyproject\.toml|uv\.lock)$
No stray debugging output. debug-statements from the standard hook set catches
breakpoint() and pdb imports. For a CLI it is worth adding a grep for print( inside command
modules if your convention is to route everything through a console or logger — a stray print
is how progress output ends up contaminating stdout.
Documentation that mentions flags stays honest. If your README shows --help output, a hook
that regenerates it and fails on a diff keeps it from drifting. This one is easy to over-engineer;
a simple version that only checks the flag list is usually enough.
Introducing hooks to an existing repository
Adding pre-commit to a codebase that has never been formatted produces a first run that touches almost every file, which makes reviewing the next change harder. Three steps avoid that.
Land the reformat on its own. Add the config, run pre-commit run --all-files, and commit the
result with a message that says exactly what it is. Nothing else in that commit.
Record it for git blame. Add the commit hash to a .git-blame-ignore-revs file and tell git
about it, so history stays readable:
echo "8f2a19c4c0b7e6d5a1f30b2c9e4d7a6b5c8e1f20" >> .git-blame-ignore-revs
git config blame.ignoreRevsFile .git-blame-ignore-revs
Turn rules on gradually. Start with formatting and the trivial file hygiene hooks. Add linting rules in batches, fixing each batch in its own commit. A configuration that emits four hundred errors on day one gets disabled on day two.
What the gates cannot catch
It is worth being clear about the boundary, because a green hook run can create false confidence.
Formatters and linters see one file at a time and know nothing about behaviour. They will not tell you that a flag was renamed, that an exit code changed meaning, that the console script points at a module that no longer exists, or that a command now writes progress to stdout. Those are the regressions that reach users, and they are caught by tests and by the install-and-run smoke check in CI — not by hooks.
The useful way to think about it: hooks protect the codebase from noise, tests protect users from breakage. Both are cheap, and neither substitutes for the other. A repository with immaculate formatting and no test that installs the built wheel is one broken entry point away from a bad release.
The corollary is that hook configuration deserves to stay small. Every check you add is time charged to every commit forever, so the bar for a new hook is that it catches something real, quickly, and that nothing later in the pipeline already catches it.
Frequently asked questions
Should formatters rewrite files in a hook, or just report?
Rewrite locally, report in CI. Locally the point is to remove the chore — the hook fixes the file,
the commit stops, you re-commit. In CI a rewrite is invisible and produces artifacts nobody sees,
so the check-only form (ruff format --check) is the right one. Same tool, same configuration,
different flag.
How do I stop pre-commit slowing everyone down?
Keep the default stage to fast, file-scoped checks and move anything slow to stages: [manual] or
to CI. Type checking a whole package, running the test suite and building documentation are all
things that belong after the commit, not before it. If the hook takes more than a couple of
seconds on a typical commit, people will start bypassing it, and a bypassed hook protects nothing.
What if a hook is wrong about my code?
Fix the configuration rather than the commit. # noqa: RULE on the one genuinely justified line
is fine; a habit of git commit -n is not, because it disables every check rather than the one
that misfired. If a rule fights the codebase repeatedly, remove the rule in pyproject.toml —
that is a decision the whole team can see and revisit.
Do hooks work with uv and Poetry projects?
Yes, and pre-commit should be installed as a standalone tool rather than as a project dependency.
It manages its own isolated environments for each hook, so it does not need to share yours, and
installing it with uv tool install or pipx means the same version is available across every
repository you work in.
Can pre-commit run the test suite?
It can, and for most projects it should not. Tests are the slowest thing in the loop and the least likely to be affected by a single staged file, so running them before every commit trains people to bypass hooks. Run them in CI, where they can take the time they need, and keep the commit-time gate to checks that finish before you have taken your hands off the keyboard.
Should the hook configuration live in the template or the project?
In the template, so every new project starts with the same set — and then owned by the project,
because a repository that cannot adjust its own linting will quietly stop running it. Treat the
template as the sensible default rather than a policy: it saves the setup work, and a project
that needs an extra rule or one fewer is free to say so in its own pyproject.toml.
Can I run only one hook while debugging?
Yes: pre-commit run ruff --all-files runs a single hook by id, and pre-commit run --files a.py
limits it to specific paths. Both are much faster than a full pass when you are iterating on a
configuration change, and neither requires committing anything.
Related
- Project Setup & Dependency Management — the track this section belongs to.
- Setting up pre-commit for Python CLI repos — the hands-on walkthrough.
- Managing CLI versioning & changelogs — the other half of a disciplined release workflow.