Pre-commit Is Worth the 5 Minutes to Set Up
You know that feeling when you push code and CI fails because of a trailing whitespace or a linting error? You fix it, push again, wait for CI to run again... It's annoying. Pre-commit fixes that.
Pre-commit catches these issues before you even commit. It takes five minutes to set up and saves you hours over time.
What is it?
Pre-commit is a framework for managing git hooks. Git hooks are scripts that run at certain points in your git workflow - before committing, before pushing, etc.
Pre-commit makes it easy to configure and share these hooks. You define what checks to run in a config file, and pre-commit handles the rest.
When you try to commit, pre-commit runs your checks. If something fails, the commit is blocked. You fix the issue locally, then commit again. No waiting for CI. No wasted push/fail/fix/push cycles.
Think of it like spell-check for your code. It catches the obvious mistakes before they leave your machine. And just like spell-check, once it's set up, you forget it's even there until it saves you from an embarrassing typo.
Why you should actually care
I get it - another tool to set up. But here's the thing: pre-commit pays for itself on day one.
Last week I was working on a feature. I committed, pushed, and went to grab coffee. Came back to a failed CI run because I left a console.log() in my code. Fixed it, pushed again, waited another 5 minutes for CI. That's 10 minutes wasted on something that could have been caught in 2 seconds locally.
Multiply that by every developer on your team, every day. That's hours of wasted time and context switching. And that's just for obvious stuff like console logs. What about:
- Debug prints you forgot to remove
- Accidentally committed
.envfiles - Broken YAML in your config files
- Code that doesn't match your team's style guide
- Secrets that snuck into your code
- Large files that bloat your git history
Pre-commit catches all of this before it becomes a problem. The feedback is instant, and you fix it while you're still in the context of writing that code.
Installation and setup
First, install pre-commit as a dev dependency:
uv add --dev pre-commit
Then install the git hooks:
uv run pre-commit install
This sets up the git hook. Now you need to tell it what to run.
The config file
Create .pre-commit-config.yaml in your project root:
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.8.0
hooks:
- id: ruff
args: [--fix]
- id: ruff-format
This runs ruff for linting (with auto-fix) and formatting on every commit. The rev is the version to use - update it periodically.
One thing to note: pre-commit downloads and caches these tools automatically. You don't need to have ruff installed globally or in your project dependencies. Pre-commit manages isolated environments for each hook. This is huge because it means:
- Your hooks run the same way for everyone on the team
- You don't pollute your project dependencies with dev tools
- Different projects can use different versions of the same tool
My complete config
Here's what I actually use for Python projects:
repos:
# Standard pre-commit hooks
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-json
- id: check-toml
- id: check-merge-conflict
- id: check-added-large-files
args: ['--maxkb=500']
# Ruff for Python
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.8.0
hooks:
- id: ruff
args: [--fix]
- id: ruff-format
Let me explain what each hook does:
trailing-whitespace: Removes trailing spaces from lines. These are invisible but show up in diffs and can cause merge conflicts.
end-of-file-fixer: Ensures files end with a single newline. Some tools expect this, and it makes diffs cleaner.
check-yaml/json/toml: Validates config file syntax. Catches typos before they break your CI or app.
check-merge-conflict: Prevents committing files with unresolved merge conflict markers (<<<<<<<, =======, >>>>>>>).
check-added-large-files: Prevents accidentally committing large files that shouldn't be in git (like data files or binaries).
ruff: Python linter. The --fix flag auto-fixes what it can.
ruff-format: Code formatter. Ensures consistent style.
What it looks like in action
When you commit with issues:
$ git commit -m 'add new feature'
trailing whitespace..............................................Failed
- hook id: trailing-whitespace
- exit code: 1
- files were modified by this hook
Fixing src/feature.py
ruff.............................................................Passed
ruff-format......................................................Passed
The trailing whitespace hook found an issue and fixed it. But because files were modified, the commit was blocked. You need to stage the fixed files and commit again:
$ git add .
$ git commit -m 'add new feature'
trailing whitespace..............................................Passed
ruff.............................................................Passed
ruff-format......................................................Passed
[main abc1234] add new feature
Now it passes and the commit goes through.
Running hooks manually
You can run hooks without committing:
# Run all hooks on all files
uv run pre-commit run --all-files
# Run a specific hook
uv run pre-commit run ruff --all-files
# Run on staged files only (what happens on commit)
uv run pre-commit run
This is useful when you first add pre-commit to a project. Run it on all files to fix everything at once.
I also run --all-files after updating my config or pulling changes that modified .pre-commit-config.yaml. It ensures the entire codebase stays consistent.
Common hooks you should know about
The pre-commit ecosystem has hundreds of hooks. Here are some that I use across different types of projects:
For Python projects
# Type checking with mypy
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.11.1
hooks:
- id: mypy
additional_dependencies: [types-all]
Mypy catches type errors before runtime. If you're using type hints, this is essential.
# Security checks with bandit
- repo: https://github.com/PyCQA/bandit
rev: 1.7.9
hooks:
- id: bandit
args: [-c, pyproject.toml]
Bandit finds common security issues like hardcoded passwords or SQL injection vulnerabilities.
For JavaScript/TypeScript projects
# ESLint
- repo: https://github.com/pre-commit/mirrors-eslint
rev: v9.9.0
hooks:
- id: eslint
files: \.[jt]sx?$
types: [file]
# Prettier
- repo: https://github.com/pre-commit/mirrors-prettier
rev: v3.3.3
hooks:
- id: prettier
For any project
# Detect secrets
- repo: https://github.com/Yelp/detect-secrets
rev: v1.5.0
hooks:
- id: detect-secrets
args: ['--baseline', '.secrets.baseline']
This one has saved me multiple times. It scans for API keys, passwords, and other secrets before they make it into git. Once a secret is in git history, it's a pain to remove.
# Check for case conflicts
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
hooks:
- id: check-case-conflict
Prevents issues where files like File.txt and file.txt would conflict on case-insensitive file systems (looking at you, macOS and Windows).
Keeping hooks updated
Hooks are pinned to specific versions in the config. To update them:
uv run pre-commit autoupdate
This updates all the rev values to the latest versions. Do this periodically to get new rules and bug fixes.
I set a reminder to do this monthly. It takes 30 seconds and keeps your hooks current. After updating, run pre-commit run --all-files to make sure nothing breaks.
Performance considerations
One concern I hear a lot: "Won't this slow down my commits?"
Short answer: a little, but not enough to matter.
My config with 10+ hooks takes about 2-3 seconds to run on a typical commit. That's nothing compared to the minutes you'd spend waiting for CI to fail and fix the issue remotely.
But if you're working on a huge monorepo or your hooks are genuinely slow, here are some tricks:
Only run on changed files
Most hooks are smart about this by default. They only check files you modified. You can verify this by looking at the files parameter in hook configs:
hooks:
- id: mypy
files: \.py$ # Only runs on .py files
Skip slow hooks for local commits
You can set up different hook stages:
repos:
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.11.1
hooks:
- id: mypy
stages: [push] # Only runs on git push, not commit
This way, expensive type checking only runs when you push, not on every commit.
Use local hooks for project-specific scripts
Sometimes you want to run a project-specific script. You can define local hooks:
repos:
- repo: local
hooks:
- id: tests
name: run tests
entry: pytest tests/unit
language: system
pass_filenames: false
always_run: true
stages: [push]
This runs your unit tests before pushing. The always_run: true means it runs even if no files match (useful for tests that should always run).
When to skip it
Sometimes you need to bypass the hooks:
git commit --no-verify -m 'WIP: broken but saving progress'
The --no-verify flag (or -n for short) skips all hooks. Use it for:
- WIP commits on feature branches
- Emergency fixes where you'll clean up later
- When you're absolutely sure the hooks are wrong
But don't make it a habit. The whole point is to catch issues early.
Don't use --no-verify to bypass a legitimate failure. If the hook is consistently wrong or annoying, fix the hook configuration instead. I've seen teams where everyone bypasses pre-commit because one hook is overly strict. That defeats the whole purpose.
Troubleshooting common issues
"Hook failed but I can't tell why"
Some hooks don't provide great error messages. Add verbose: true to the hook to get more details:
hooks:
- id: mypy
verbose: true
"Hooks take forever on first run"
Pre-commit needs to download and set up environments for each hook on first run. This can take a minute or two. Subsequent runs are fast because everything is cached.
If it's truly slow, check if you're on a slow network or if something is misconfigured.
"Hooks pass locally but fail in CI"
Usually this means someone didn't run pre-commit install locally, or they're using --no-verify. Make sure your CI runs the same pre-commit config:
# .github/workflows/ci.yml
- name: Run pre-commit
uses: pre-commit/action@v3.0.1
This catches people who skip the hooks.
"I updated my config but hooks aren't changing"
Pre-commit caches hook environments. If you change a hook's config and it's not taking effect, clear the cache:
uv run pre-commit clean
uv run pre-commit install --install-hooks
This forces pre-commit to rebuild the hook environments.
Real-world impact: A case study
Let me give you a concrete example from a team I worked with.
Before pre-commit:
- 30% of CI runs failed on linting/formatting issues
- Average time to fix and re-push: 8 minutes
- Team of 10 developers, ~50 commits per day
- That's 15 failed CI runs × 8 minutes = 2 hours wasted daily
After pre-commit:
- CI failures for linting/formatting: ~2% (usually when someone bypassed hooks)
- Time saved: ~1.75 hours daily
- Setup time: 30 minutes total (5 minutes per dev + initial config)
ROI was immediate. And that doesn't count the intangible benefits:
- Cleaner git history (no "fix linting" commits)
- Less context switching (catch issues immediately vs waiting for CI)
- Better code review focus (reviewers aren't nitpicking formatting)
CI integration
Pre-commit runs the same checks locally that CI would run. This means:
- Fewer failed CI runs (you caught the issues locally)
- Faster feedback (no waiting for CI)
- Less wasted CI minutes
You can also run pre-commit in CI as a safety net:
# In your GitHub Actions workflow
- uses: pre-commit/action@v3.0.1
This catches cases where someone forgot to run pre-commit install.
Team adoption
When you add pre-commit to a project, everyone on the team needs to run:
uv run pre-commit install
Add it to your README or contributing guide. The config file is committed to the repo, so everyone uses the same hooks.
Here's what I put in my README:
## Development Setup
1. Install dependencies: `uv sync`
2. Install pre-commit hooks: `uv run pre-commit install`
3. (Optional) Run hooks on all files: `uv run pre-commit run --all-files`
Some teams go further and add a setup script that does this automatically:
#!/bin/bash
# setup.sh
uv sync
uv run pre-commit install
uv run pre-commit run --all-files
echo "Setup complete!"
Now new developers just run ./setup.sh and they're good to go.
Getting buy-in
If you're introducing pre-commit to an existing team, expect some resistance. Developers don't like new overhead, especially if it feels like it slows them down.
Here's what worked for me:
-
Start with minimal hooks: Just the basics (formatting, trailing whitespace). Don't introduce 20 hooks on day one. Let the team see the value first.
-
Show the time savings: Track CI failures for a week before and after. Concrete numbers convince people.
-
Make it optional at first: Let people try it voluntarily. Once they see it catching bugs before CI, they'll want to keep it.
-
Fix issues, don't bypass: When hooks are too strict or wrong, fix the config. Don't let the team build a habit of bypassing.
-
Run in CI as a safety net: This catches people who don't use hooks locally, without blocking their workflow.
After a few weeks, pre-commit becomes invisible. It just works.
Advanced techniques
Running different hooks for different file types
You can target specific file patterns:
repos:
- repo: local
hooks:
- id: check-sql
name: Check SQL files
entry: sqlfluff lint
language: system
files: \.sql$
This only runs on .sql files. Great for projects with multiple languages or file types.
Excluding files or patterns
Sometimes you need to skip certain files:
hooks:
- id: ruff
exclude: ^(migrations/|vendor/)
This excludes database migrations and vendored code from linting.
Custom error messages
You can add custom messages when hooks fail:
repos:
- repo: local
hooks:
- id: check-api-key
name: Check for API keys
entry: bash -c 'if grep -r "sk-" --include="*.py" .; then echo "ERROR: Found potential API key in code!"; exit 1; fi'
language: system
pass_filenames: false
When this fails, developers see your custom error message explaining what went wrong.
Alternatives and comparisons
You might be wondering: why pre-commit instead of just running linters manually or in CI?
Manual linting: Easy to forget. Inconsistent across the team. You remember to run it 60% of the time at best.
Only in CI: Slow feedback loop. You've already context-switched to something else by the time CI fails. Wastes CI minutes.
IDE plugins: Great for real-time feedback while coding. But they don't enforce consistency. One person's IDE might be configured differently. Pre-commit is the enforcement layer that catches what IDE plugins miss.
Git hooks without pre-commit: You can write raw git hooks in .git/hooks/, but they don't get committed to the repo. Everyone needs to set them up manually. Pre-commit solves this by making hooks shareable and version-controlled.
The best setup? IDE plugins + pre-commit + CI checks. Three layers of defense.
The bottom line
Pre-commit takes five minutes to set up and catches issues before they become CI failures. The feedback loop is faster (instant vs waiting for CI), and you spend less time on annoying formatting and whitespace issues.
After using it for a year, I can't imagine working without it. It's one of those tools that fades into the background but quietly saves you hours every week.
The initial resistance from teams is always the same: "Another tool? Really?" But after a week, nobody complains. After a month, they're adding more hooks. After six months, they're advocating for it on their next project.
That's the sign of a good tool - one that just works and gets out of your way.
Start small. Add the basic config I showed you. Run it for a week. See how many silly issues it catches before CI. Then decide if those five minutes of setup were worth it.
Spoiler: they were.