TL;DR:
- Pre-commit hooks that are slow get disabled — the most common failure mode is that developers bypass hooks with
--no-verifyafter they slow down - Lefthook runs hooks in parallel and is significantly faster than Husky for repos with multiple checks; Husky is simpler for single-check setups
- The right hook setup catches formatting issues, failing tests for changed files, and type errors before a single broken commit hits CI
The premise of pre-commit hooks is sound: catch problems at the point of authorship, before they become a CI failure two minutes later. The practice often falls apart because hooks are slow, or inconsistently configured across the team, or people just run git commit --no-verify whenever they’re in a hurry.
This guide covers setting up hooks that are fast enough that nobody wants to bypass them — and consistent enough that they work the same on every machine.
The Two Main Contenders
Husky has been the standard for JavaScript/TypeScript projects for years. It patches the package.json scripts to install git hooks and is straightforward to set up. The mental model is simple: one hook = one shell script.
Lefthook is a Go binary (no Node.js dependency) that runs hook commands in parallel. It’s available for any language project, not just JS/TS, and its YAML configuration is more declarative than Husky’s shell scripts. The parallel execution is the critical advantage: if you’re running a linter, a formatter check, and a type check, Lefthook runs all three simultaneously rather than sequentially.
Setting Up Lefthook
Install as a dev dependency in your project:
# npm
npm install --save-dev lefthook
# or as a global binary (works with any project)
brew install lefthook # macOS
# or download from github.com/evilmartians/lefthook/releases
Create lefthook.yml in your project root:
pre-commit:
parallel: true
commands:
lint:
glob: "*.{js,ts,jsx,tsx}"
run: npx eslint {staged_files}
format:
glob: "*.{js,ts,jsx,tsx,css,md}"
run: npx prettier --check {staged_files}
type-check:
run: npx tsc --noEmit
pre-push:
commands:
tests:
run: npm test -- --passWithNoTests
Then install the hooks:
npx lefthook install
The key features in this config:
parallel: true— lint, format, and type-check run simultaneouslyglobfiltering — only runs eslint on JS/TS files, not on markdown or images{staged_files}— passes only the files actually staged for commit to the linter, not the entire project
That last point matters a lot. Running ESLint on your entire 50,000-line codebase on every commit takes 30 seconds. Running it on the 3 files you just changed takes under a second.
Setting Up Husky (for Comparison)
For projects that prefer staying within the npm ecosystem:
npm install --save-dev husky lint-staged
# Initialize Husky
npx husky init
Add to package.json:
{
"lint-staged": {
"*.{js,ts,jsx,tsx}": ["eslint --fix", "prettier --write"],
"*.{css,md,json}": ["prettier --write"]
}
}
Edit .husky/pre-commit:
#!/bin/sh
npx lint-staged
Husky + lint-staged is a well-understood combination and works reliably. The limitation: commands run sequentially within lint-staged, so if you have both ESLint and TypeScript checking, they queue rather than run in parallel.
What Checks to Include
The right balance: fast checks on pre-commit, slower checks on pre-push.
Pre-commit (should be under 10 seconds):
- Formatter check (Prettier, Biome, Black, gofmt — milliseconds to seconds)
- Linter on changed files only (ESLint, Ruff, golangci-lint with
--new-from-rev) - No secrets check (detect-secrets, gitleaks — fast scan of staged files)
Pre-push (can be up to 60 seconds):
- Type checking (TypeScript
tsc --noEmit, mypy for Python) - Tests for changed modules (pytest with
--testpathsor Jest with--findRelatedTests) - Build verification for critical packages in a monorepo
Do not put on any hook:
- Full test suite (too slow — belongs in CI)
- Database migrations check (too slow)
- Anything requiring network access (unreliable)
The No-Verify Problem
The most common failure mode for pre-commit hooks: git commit --no-verify becomes a habit whenever someone is in a hurry. There’s no technical solution to this — --no-verify is a legitimate flag.
The practical solution is to keep hooks fast enough that bypassing them feels unnecessary. If your pre-commit hook runs in under 5 seconds, almost nobody will bypass it. If it runs in 45 seconds, everyone will bypass it regularly.
Track bypass rate in your team’s retrospectives if this is a real problem. A hook that 30% of commits bypass isn’t protecting anything — either make it faster or move it to CI only.
Cross-Language Projects
Lefthook’s Go binary approach means it works well for polyglot repos where not every language uses npm. A monorepo with a Go backend, TypeScript frontend, and Python data pipeline can share one lefthook.yml:
pre-commit:
parallel: true
commands:
go-lint:
glob: "*.go"
run: golangci-lint run {staged_files}
ts-lint:
glob: "*.{ts,tsx}"
run: npx eslint {staged_files}
python-format:
glob: "*.py"
run: ruff check {staged_files}
Each command only runs when files matching its glob are staged. Commit a Python file, only ruff runs. Commit a Go file, only golangci-lint runs. Commit both, they run in parallel.
Sharing Configuration Across a Team
Both tools need hooks installed on each developer’s machine. The most reliable way:
Add install to the project setup script:
# setup.sh
npm install
npx lefthook install
echo "Git hooks installed"
Document in your README that new developers run ./setup.sh after cloning. Some teams add lefthook install to the postinstall npm script so it runs automatically on npm install — though this only works for npm-managed projects.
For monorepos: put lefthook.yml at the repo root and reference sub-package scripts with paths. Hooks install once at the root level.
Checking What Hooks Are Configured
# See what hooks are installed
ls .git/hooks/
# Run pre-commit hook manually (useful for testing)
npx lefthook run pre-commit
# Run just one command from a hook
npx lefthook run pre-commit --commands lint
Running hooks manually before committing is useful when you want to catch issues without actually making a commit. It’s also how you verify that your hooks are actually working after installation.
Pre-commit hooks are a high-return investment: they eliminate a category of CI failures entirely, make code review faster by ensuring formatting is consistent, and build good habits without requiring developers to remember to run checks manually. The key is keeping them fast — a hook that feels like friction will be turned off.