What role do pre-commit hooks and CI checks play in a Python project?
Quick Answer
**Pre-commit hooks** run fast checks (formatting, linting, basic syntax) automatically on `git commit`, catching and often auto-fixing problems **before** they're even committed, giving the fastest possible feedback loop. **CI checks** re-run those same checks (as a safety net for anyone who skipped/bypassed the local hook) plus slower checks unsuitable for every commit (the full test suite, type checking, security scanning), gating whether a PR can merge.
Detailed Answer
Pre-commit: catch problems before they're even committed
# .pre-commit-config.yaml
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.6.0
hooks:
- id: ruff
- id: ruff-format
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
hooks:
- id: trailing-whitespace
- id: check-merge-conflict
- id: check-added-large-files
pre-commit install # one-time, wires hooks into .git/hooks/pre-commit
git commit -m "..." # hooks run automatically; a failing hook blocks the commit
Once installed, every git commit automatically runs the configured
checks against the changed files — a formatting violation gets
auto-fixed by ruff-format right then (and the commit is blocked
until you re-stage the fixed files), and a lint error is reported
immediately, long before a reviewer or CI would otherwise see it. This is
the fastest possible feedback loop: seconds, on your own machine, before
the change even leaves your laptop.
CI: the safety net and the home for slower checks
# .github/workflows/ci.yml (simplified)
jobs:
test:
steps:
- run: pip install -e ".[dev]"
- run: ruff check .
- run: mypy src/
- run: pytest --cov=mypackage
CI re-runs the same fast checks (in case someone used git commit --no-verify to skip hooks, or hasn't installed pre-commit locally at
all) and also runs checks too slow or heavyweight for every single
commit: the full test suite (which might take minutes), static type
checking across the whole codebase, security scanning, and building the
actual package to confirm it installs cleanly.
Why both layers, not just one
| Pre-commit | CI | |
|---|---|---|
| Runs on | changed files, at commit time | the full PR, on every push |
| Speed | seconds | can be minutes |
| Enforced for everyone | only if hooks are installed locally | always (it's a required check gating merge) |
| Good for | fast, auto-fixable checks (format, lint) | anything, especially slow/expensive checks (full test suite, type checking) |
Pre-commit alone isn't sufficient because it's opt-in per developer
machine (someone can skip installing it, or bypass it with
--no-verify) — CI is the actual enforced gate. Pre-commit exists
purely to shorten the feedback loop so most problems never reach CI (and
therefore never cost a reviewer's attention or a slow CI run) in the
first place.
A well-configured pipeline layers both
- Pre-commit (local, instant): formatting, basic linting, trailing whitespace, merge-conflict markers, large-file checks.
- CI (per-PR, required to merge): everything pre-commit checks again (as a backstop) + the full test suite + type checking + build verification + (often) security/dependency scanning.
Interview-ready summary: Pre-commit hooks give instant, local, often-auto-fixing feedback on fast checks (formatting, linting) before a commit is even made; CI is the actually-enforced gate that re-runs those checks as a safety net and additionally runs everything too slow for every commit (full test suite, type checking, security scans) before a PR can merge. Neither replaces the other — pre-commit shortens the feedback loop, CI guarantees the standard is actually met.