Background: Why Our Team Started Using Pre-commit
When our team was just 4–5 people, code reviews were pretty relaxed — everyone knew each other and understood each other’s coding style. But when we grew to 8 people with 3 seniors, 2 mid-levels, and 3 new juniors, things started getting messy. Every PR had comments like “missing newline at end of file”, “tabs instead of spaces”, “imports not sorted”. Reviewers wasted time on it, and the people being reviewed got frustrated by comments about things that a tool should catch automatically.
I decided to adopt the pre-commit framework after seeing an open source repo use it. The result: style/format comments in PRs dropped to nearly zero, and reviewers could focus on logic instead of trailing commas. In our team of 8, I applied pre-commit alongside the Git flow we already had — and merge conflicts also dropped significantly because code became more consistent with fewer formatting conflicts.
Pre-commit is a framework for managing Git hooks — specifically the pre-commit hook that runs right before git commit creates a commit. If any hook fails, the commit is blocked. What sets it apart from writing hooks manually is that pre-commit manages each hook’s dependencies separately, without touching your project’s Python or Node environment — each hook runs in its own isolated virtualenv.
Installing Pre-commit
Requirements
Pre-commit requires Python 3.8+ and pip. Check first:
python --version # Python 3.8.x or higher
pip --version
Installing Globally or in a Virtualenv
# Install globally
pip install pre-commit
# Or with pipx (isolated, cleaner — this is what I use)
pipx install pre-commit
# Check version
pre-commit --version
# pre-commit 3.7.x
Activating Hooks for Your Repo
Once installed, navigate to your repo directory and run a single command:
cd /path/to/your/repo
pre-commit install
This creates the .git/hooks/pre-commit file — from now on, every time you run git commit, pre-commit runs automatically. To also block git push, add:
pre-commit install --hook-type pre-push
Detailed Configuration with .pre-commit-config.yaml
All configuration lives in the .pre-commit-config.yaml file at the root of your repo. Since this file is committed to git, everyone on the team automatically shares the same config — no need for individual setup.
Basic Configuration for a Python Project
# .pre-commit-config.yaml
repos:
# Basic hooks from pre-commit-hooks
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
hooks:
- id: trailing-whitespace # Remove trailing whitespace
- id: end-of-file-fixer # Ensure files end with a newline
- id: check-yaml # Validate YAML syntax
- id: check-json # Validate JSON syntax
- id: check-merge-conflict # Detect leftover conflict markers
- id: check-added-large-files # Block oversized files (prevent accidental binary commits)
args: ['--maxkb=500']
- id: debug-statements # Detect leftover print(), breakpoint() calls
# Black — code formatter for Python
- repo: https://github.com/psf/black
rev: 24.4.2
hooks:
- id: black
language_version: python3
# isort — sort import statements
- repo: https://github.com/PyCQA/isort
rev: 5.13.2
hooks:
- id: isort
args: ["--profile", "black"] # Compatible with Black
# Flake8 — linter for logic and style errors
- repo: https://github.com/PyCQA/flake8
rev: 7.0.0
hooks:
- id: flake8
args: ['--max-line-length=88'] # Match Black's default line length
Adding Hooks for JavaScript/TypeScript
For projects mixing Python and JS (API backend + frontend), add to the repos section:
# Prettier — format JS/TS/CSS/JSON
- repo: https://github.com/pre-commit/mirrors-prettier
rev: v4.0.0-alpha.8
hooks:
- id: prettier
types_or: [javascript, typescript, css, json]
# ESLint
- repo: https://github.com/pre-commit/mirrors-eslint
rev: v9.4.0
hooks:
- id: eslint
files: \.(js|ts|jsx|tsx)$
types: [file]
Local Hooks — Writing Your Own Scripts
This is the part I find most powerful: write your own hooks without publishing them anywhere — just keep them in the repo:
# Local hooks — run scripts directly in the repo
- repo: local
hooks:
- id: no-leftover-todos
name: Check for leftover TODO/FIXME in staged files
entry: grep -n "TODO\|FIXME\|HACK"
language: system
types: [python]
pass_filenames: true
- id: run-unit-tests
name: Run unit tests
entry: python -m pytest tests/unit -q --tb=short
language: system
pass_filenames: false
stages: [pre-push] # Only runs on push, not on every commit
Note the stages: [pre-push] — heavy tests belong in pre-push, lightweight linting in pre-commit, so you don’t slow down your daily commit workflow.
Auto-updating Hook Versions
Once you’ve written your config, run this command to update the rev of all hooks to their latest versions:
pre-commit autoupdate
I schedule this to run once a month — to avoid using outdated hooks with known security issues.
Testing and Monitoring
Running Manually Before Committing
To check the entire codebase without creating a dummy commit:
# Run all hooks on all files
pre-commit run --all-files
# Run only a specific hook
pre-commit run black --all-files
pre-commit run flake8 --all-files
# Run on specific files
pre-commit run --files src/main.py src/utils.py
The first time you run it, pre-commit downloads and installs a separate environment for each hook — this takes about 1–2 minutes. After that, everything is cached and runs immediately.
Reading Output When a Hook Fails
Here’s the actual output when a commit is blocked:
$ git commit -m "Add user authentication"
[INFO] Stashing unstaged changes to tracked files.
Trim Trailing Whitespace.................................................Passed
Fix End of Files.........................................................Passed
Check for merge conflicts................................................Passed
black....................................................................Failed
- hook id: black
- files were modified by this hook
reformatted src/auth.py
isort....................................................................Failed
- hook id: isort
- files were modified by this hook
Fixing src/auth.py
Black and isort automatically fix the files — just run git add src/auth.py and commit again. With flake8 it’s different: it reports errors but doesn’t auto-fix them, so you’ll need to fix them manually based on the output.
Skipping Hooks When You Really Need To
# Skip all hooks (emergency hotfix)
git commit --no-verify -m "hotfix: production down"
# Skip a specific hook
SKIP=flake8 git commit -m "WIP: will fix lint later"
In our team, we have an agreement: anyone who uses --no-verify must clearly state the reason in the commit message and create a ticket to fix it within 1 day. It’s not a strict rule, but it’s enough to keep everyone accountable.
Integrating with CI/CD to Ensure No One Skips It
Local hooks only run on developer machines — others can still push without installing pre-commit. Add a GitHub Actions workflow so CI blocks them too:
# .github/workflows/lint.yml
name: Lint Check
on: [push, pull_request]
jobs:
pre-commit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- uses: pre-commit/[email protected] # Official action from pre-commit
This action reuses pre-commit’s cache and typically takes only 30–60 seconds in CI — much lighter than running the full test suite.
Onboarding New Team Members
After a new member clones the repo, they only need to run 2 commands:
pip install pre-commit
pre-commit install
I added these 2 commands to the Makefile setup target and the README so no one forgets. One more tip: run pre-commit run --all-files right during onboarding — so the new member can see the current state of the codebase and fix all legacy lint issues from day one, rather than letting them accumulate.
After the first month of rolling it out across our 8-person team, CI failures due to style issues dropped from ~40% to under 5%. More importantly, code reviews started focusing on architecture and logic — the things that actually require human judgment — instead of trailing commas that a tool can handle in seconds.

