TL;DR
husky is the most popular Git hooks manager for Node.js — simple setup, integrates with npm scripts, and runs hooks defined as shell scripts. lefthook is the fast multi-language alternative — written in Go, runs hooks in parallel, ~10x faster than husky for large projects. lint-staged is not a hook runner but a companion tool — it runs linters and formatters only on files staged for commit (not your entire codebase). In 2026: use husky + lint-staged together for the industry-standard pre-commit setup, or lefthook as an all-in-one faster alternative.
Key Takeaways
- husky: ~5M weekly downloads — de facto Git hooks standard for Node.js, simple
.husky/directory - lefthook: ~400K weekly downloads — Go-based, parallel execution, monorepo-aware, no Node.js required
- lint-staged: ~8M weekly downloads — runs linters only on staged files, dramatically faster than full codebase lint
- husky + lint-staged is the industry standard — used by React, Next.js, Vite, and thousands of OSS projects
- lefthook supports running commands in parallel within a hook — pre-commit can lint + type-check simultaneously
- lint-staged works with any hook runner — husky, lefthook, or plain Git hooks
The Problem Git Hooks Solve
Without Git hooks:
1. Developer commits broken code
2. CI catches the issue 5 minutes later
3. Developer context-switches back to fix it
→ Slow feedback loop, inconsistent code quality
With pre-commit hooks:
1. Developer runs git commit
2. Hook: ESLint runs, finds error → commit rejected
3. Developer fixes immediately, recommits
→ Fast feedback, consistent quality, no CI noise
husky
husky — Git hooks for Node.js:
Setup
npm install -D husky
# Initialize husky (creates .husky/ directory, updates package.json):
npx husky init
# Creates:
# .husky/pre-commit (sample hook)
# package.json: "prepare": "husky" (installs hooks on npm install)
Hook files
# .husky/pre-commit
npm run lint
npm run type-check
# .husky/commit-msg
# Validate commit message format (requires commitlint):
npx --no -- commitlint --edit $1
# .husky/pre-push
npm run test
# Hook files are just shell scripts — make executable:
chmod +x .husky/pre-commit
# Test a hook manually:
./.husky/pre-commit
With lint-staged (recommended)
# Install both:
npm install -D husky lint-staged
# .husky/pre-commit:
npx lint-staged
# package.json (lint-staged config):
{
"lint-staged": {
"*.{ts,tsx}": ["eslint --fix", "prettier --write"],
"*.{js,jsx}": ["eslint --fix"],
"*.{css,md,json}": ["prettier --write"]
}
}
commitlint integration
npm install -D @commitlint/cli @commitlint/config-conventional
# .husky/commit-msg:
npx --no -- commitlint --edit $1
# commitlint.config.js:
export default {
extends: ["@commitlint/config-conventional"],
rules: {
"type-enum": [2, "always", [
"feat", "fix", "docs", "style", "refactor",
"perf", "test", "chore", "ci", "revert"
]],
"subject-max-length": [2, "always", 100],
},
}
# Valid: "feat: add user authentication"
# Invalid: "add stuff" → hook rejects, shows error
CI environments
# husky skips installation in CI automatically (HUSKY=0 or CI env var):
# Or disable manually:
HUSKY=0 npm ci # Skip husky in CI pipeline
lint-staged
lint-staged — run linters on staged Git files:
Why lint-staged matters
Without lint-staged:
Pre-commit: eslint src/ → lints all 500 files → ~30 seconds
You changed: 2 files
With lint-staged:
Pre-commit: eslint <staged> → lints 2 changed files → ~0.5 seconds
Much faster, much more practical
Configuration
// package.json
{
"lint-staged": {
// Glob patterns → commands to run on matching staged files:
"*.{ts,tsx}": [
"eslint --fix --max-warnings 0",
"prettier --write"
],
"*.{js,mjs,cjs}": [
"eslint --fix"
],
"*.{json,yaml,yml}": [
"prettier --write"
],
"*.md": [
"prettier --write --prose-wrap always"
]
}
}
// Or as lint-staged.config.ts (TypeScript config):
import type { Config } from "lint-staged"
const config: Config = {
"*.{ts,tsx}": (files) => {
// files = array of staged file paths
// Return command(s) as string or string[]:
return [
`eslint --fix ${files.join(" ")}`,
`prettier --write ${files.join(" ")}`,
]
},
"*.{css,scss}": ["stylelint --fix", "prettier --write"],
}
export default config
Advanced patterns
{
"lint-staged": {
// Run type-check on ALL TypeScript files when any .ts changes:
"*.{ts,tsx}": [
"eslint --fix",
"prettier --write",
// Type-check the whole project (not just staged files):
// bash -c "tsc --noEmit"
],
// Run tests for changed files:
"src/**/*.test.{ts,tsx}": "vitest run",
// Validate package.json:
"package.json": "node -e \"JSON.parse(require('fs').readFileSync('package.json'))\""
}
}
lefthook
lefthook — fast polyglot Git hooks manager:
Setup
npm install -D @lintrunner/lefthook
# Or (recommended — standalone binary):
npx lefthook install
# Creates: lefthook.yml
lefthook.yml
# lefthook.yml
pre-commit:
parallel: true # Run all commands in parallel!
commands:
lint:
glob: "*.{ts,tsx,js}"
run: npx eslint --fix {staged_files}
stage_fixed: true # Re-stage files after auto-fix
format:
glob: "*.{ts,tsx,js,json,css,md}"
run: npx prettier --write {staged_files}
stage_fixed: true
type-check:
run: npx tsc --noEmit
commit-msg:
commands:
commitlint:
run: npx commitlint --edit {1}
pre-push:
commands:
tests:
run: npm test
Parallel execution (lefthook's killer feature)
# husky runs sequentially:
# eslint → 5s
# prettier → 2s
# tsc → 8s
# Total: 15s
# lefthook with parallel: true
# eslint ┐
# prettier ┤ all at once → 8s (longest task)
# tsc ┘
Monorepo support
# lefthook.yml (root)
pre-commit:
commands:
# Only run in changed packages:
lint-api:
root: "packages/api/"
glob: "*.ts"
run: npm run lint
lint-ui:
root: "packages/ui/"
glob: "*.{ts,tsx}"
run: npm run lint
# Root-level check:
type-check-all:
run: npm run type-check -w
Skip hooks
# Skip all hooks for a commit (emergency):
git commit --no-verify -m "emergency fix"
# Skip specific lefthook commands:
LEFTHOOK_EXCLUDE=type-check git commit -m "wip"
# Disable lefthook in CI:
LEFTHOOK=0 git push
Feature Comparison
| Feature | husky | lefthook | lint-staged |
|---|---|---|---|
| Primary role | Hook runner | Hook runner | Staged-file filter |
| Language | Node.js | Go (binary) | Node.js |
| Parallel hooks | ❌ | ✅ | N/A |
| Lint only staged files | ❌ | ✅ ({staged_files}) | ✅ (core feature) |
| Monorepo support | Manual | ✅ First-class | ✅ |
| Config format | Shell scripts | YAML | JSON / JS |
| Auto re-stage fixed files | ❌ | ✅ (stage_fixed: true) | ✅ |
| Non-Node.js projects | ❌ | ✅ | ❌ |
| Weekly downloads | ~5M | ~400K | ~8M |
Recommended Setups
Standard Node.js project (husky + lint-staged)
// package.json
{
"scripts": {
"prepare": "husky",
"lint": "eslint src/",
"type-check": "tsc --noEmit"
},
"lint-staged": {
"*.{ts,tsx,js,jsx}": ["eslint --fix", "prettier --write"],
"*.{json,md,css,yml}": ["prettier --write"]
},
"devDependencies": {
"husky": "^9.0.0",
"lint-staged": "^15.0.0",
"eslint": "^9.0.0",
"prettier": "^3.0.0"
}
}
# .husky/pre-commit
npx lint-staged
# .husky/commit-msg
npx --no -- commitlint --edit $1
Monorepo or performance-sensitive (lefthook)
# lefthook.yml
pre-commit:
parallel: true
commands:
eslint:
glob: "*.{ts,tsx,js}"
run: npx eslint --fix {staged_files}
stage_fixed: true
prettier:
glob: "*.{ts,tsx,js,json,css,md}"
run: npx prettier --write {staged_files}
stage_fixed: true
typecheck:
run: npx tsc --noEmit --skipLibCheck
commit-msg:
commands:
commitlint:
run: npx commitlint --edit {1}
When to Use Each
Choose husky + lint-staged if:
- Node.js project — the industry standard, well-documented everywhere
- Need maximum compatibility with tutorials, docs, and community help
- Simple hook needs (lint on commit, test on push)
Choose lefthook if:
- Large codebase where sequential hooks are too slow
- Monorepo with per-package hook configuration
- Non-Node.js projects (Go, Ruby, Python) that need Git hooks
- Want auto re-staging of auto-fixed files without extra config
Use lint-staged with either runner:
- Always use lint-staged to scope linting to staged files only
- Dramatically reduces pre-commit time from 30s → 1-2s
- Works with husky, lefthook, or plain
.git/hooks/
Migration Guide
From husky to lefthook
Lefthook replaces husky's shell scripts with a single YAML config. The migration involves removing husky's install hooks and translating each .husky/ script:
# Remove husky
npm uninstall husky
rm -rf .husky
# Remove "prepare": "husky" from package.json
# Install lefthook
npm install -D lefthook
npx lefthook install
# lefthook.yml — replaces .husky/pre-commit and .husky/commit-msg
pre-commit:
parallel: true
commands:
lint:
run: npx lint-staged
typecheck:
run: npx tsc --noEmit
commit-msg:
commands:
validate:
run: npx commitlint --edit {1}
The key benefit: husky runs hooks sequentially; lefthook's parallel: true runs lint, typecheck, and other commands simultaneously, cutting pre-commit wait time significantly on large codebases.
Community Adoption in 2026
lint-staged reaches approximately 8 million weekly downloads, making it one of the most widely installed developer tooling packages despite not being a standalone hook runner. It is complementary to both husky and lefthook — virtually all teams using any pre-commit runner also use lint-staged to scope linting to staged files only. Scoping is essential: running ESLint across an entire codebase on every commit would make pre-commit hooks impractically slow.
husky sits at approximately 5 million weekly downloads, reflecting its decade-long dominance as the git hook manager for Node.js projects. Version 9 simplified configuration considerably — hooks are now plain shell scripts in .husky/, requiring no additional configuration format to learn. The prepare script ("prepare": "husky") runs automatically on npm install, making setup zero-effort for new contributors cloning a repo.
lefthook is approaching 1 million weekly downloads, growing steadily as its parallel execution model gains recognition. Originally developed at Evil Martians for Ruby on Rails projects, it has found significant adoption in TypeScript monorepos where sequential husky hooks create frustrating commit experiences. Its Go-based binary requires no Node.js runtime, making it suitable for polyglot repositories where not every contributor runs Node.js.
Team Adoption Challenges and Bypass Policies
Git hooks are only effective when the entire team runs them reliably. The organizational challenges of hook adoption are as important as the technical configuration.
Automatic hook installation is essential for team adoption. With husky, running npm install triggers hook installation automatically because the prepare script runs post-install. Lefthook provides lefthook install as a one-time setup command but does not install hooks automatically on npm install — teams must document the setup step or add "prepare": "lefthook install" to package.json themselves. lint-staged has no hook installation mechanism and must be paired with husky or another hook runner.
The --no-verify bypass (git commit --no-verify) skips all git hooks, including pre-commit. This escape hatch is sometimes necessary for emergency commits or commits from CI systems that should not run pre-commit hooks, but it can also be misused to bypass required checks. Teams that treat --no-verify as a productivity shortcut undermine the value of pre-commit hooks entirely. A reasonable policy is to allow --no-verify only with a corresponding ticket reference in the commit message explaining the bypass, and to audit usage in code review.
Performance with large repositories is a common pain point. Running ESLint across an entire large codebase on every commit is impractical — a 30-second pre-commit hook discourages commits and leads to bypass behavior. lint-staged's core value proposition is solving this: running eslint --fix only on staged files rather than the whole codebase keeps pre-commit hooks fast (typically under 5 seconds). Lefthook's glob filtering achieves the same result. Measuring pre-commit hook runtime and targeting a 3-5 second maximum keeps the experience acceptable.
Monorepo considerations require hooks that understand package scope. A commit touching only the apps/frontend package should not run tests from apps/backend. Lefthook's glob patterns can be scoped to directory prefixes. Husky with lint-staged can use package.json-level lint-staged configuration in each package (with --config pointing to the appropriate package's config). Getting this scoping right requires upfront configuration investment but is essential for monorepos where cross-package hook runs add unacceptable latency.
The most pragmatic approach for most teams: husky + lint-staged for pre-commit (fast, widely documented, excellent IDE integration), with pre-push hooks reserved for slower checks like type-checking and full test runs that should block pushes but not commits.
Security and Hook Verification in CI
Git hooks protect code quality locally but do not replace CI checks. Developers can always bypass pre-commit hooks with git commit --no-verify, and external contributors who clone the repository may not have the hook runner installed at all. CI must run the same checks — linting, type-checking, tests — independently of whether local hooks passed. The right model is layered: hooks provide fast local feedback (catching issues in seconds rather than waiting for CI), while CI provides the authoritative pass/fail gate that cannot be bypassed. For security-sensitive repositories, CI should also run secret scanning (using tools like git-secrets or trufflehog) independently of any pre-commit hook, because secrets that slipped through a bypassed hook must be caught before reaching the remote. This dual-layer approach means hook configuration and CI configuration should be kept in sync — a lint rule that exists in the hook but not in CI creates false confidence that CI will always agree with the local hook outcome.
TypeScript Project Considerations
TypeScript projects require extra care in pre-commit hook configuration because type-checking is the one operation that lint-staged's staged-file scoping cannot meaningfully apply to. ESLint can check individual files in isolation; TypeScript's type checker by definition needs to understand the entire project's type graph because cross-file type references are the core of the language. Passing only staged files to tsc will produce misleading results — it misses the type errors that arise from relationships between the changed file and unstaged files. The correct approach is to run tsc --noEmit on the full project in a pre-commit or pre-push hook, accepting that it takes longer, and to use lint-staged only for ESLint and Prettier. Teams can scope the type-check to pre-push rather than pre-commit to avoid the latency cost on every commit while still catching type errors before code reaches the remote.
Methodology
Download data from npm registry (weekly average, February 2026). Feature comparison based on husky v9.x, lefthook v1.x, and lint-staged v15.x.
Compare developer tooling and Git workflow packages on PkgPulse →
See also: Axios vs Ky and cac vs meow vs arg 2026, archiver vs adm-zip vs JSZip (2026).