Pre-Commit Hooks Developers Actually Keep Enabled
Which pre-commit hooks actually serve the developer rather than slowing them down. Concrete lint, format, and type-check setups that teams keep enabled.

I’ve watched teams adopt pre-commit hooks with enthusiasm, only to see them silently bypassed within weeks. The pattern is always the same: a hook that adds thirty seconds or more to every commit trains developers to type --no-verify as reflex, and a hook that lints the entire project instead of the staged diff invites frustration before it even runs once. If you want hooks that stay enabled, you have to optimize for speed and surgical precision.
Why Most Pre-Commit Hooks Get Disabled Within a Month
The root cause is almost always latency. A pre-commit hook that takes more than a second or two feels like a tax on every save-commit cycle. When that tax grows to 30 seconds—running a full test suite, type-checking every file, linting unchanged code—developers learn to bypass it. I’ve seen teams disable the hook entirely after one sprint, then quietly remove it from the repo’s setup docs. The second failure mode is scope creep: a hook that lints the entire project instead of the staged diff, or that runs a full test suite before allowing a commit. That kind of hook punishes everyone for changes they didn’t make, and it almost never survives the first “urgent hotfix” that gets committed with --no-verify.
The Three Hooks That Survive Production Use
Format-on-stage
A tool like lint-staged running Prettier or dprint only on the files you’ve git added. It applies formatting automatically so the diff stays clean, and the hook exits in under a second. My typical config:
// .lintstagedrc.json
{
"*.{js,ts,tsx,jsx}": ["prettier --write"],
"*.{json,md,yaml}": ["prettier --write"]
}Coupled with Husky’s pre-commit script that calls npx lint-staged, this hook never feels like a bottleneck—it applies formatting in the time it takes to register the command. No manual fixing, no cascading warnings.
Lint-with-diff
Running ESLint or Biome on the entire project after a one-line change is wasteful. The better approach is to lint only the lines changed in the staged diff. Using lint-staged with a pattern like eslint --fix against the matching files already limits scope, but if you want per-line checking, pass --diff to ESLint (ESLint v9+ supports --report-unused-disable-directives and diff-aware checking via plugins). My preference:
{
"*.{js,ts,tsx,jsx}": ["eslint --fix --max-warnings 0"]
}This catches formatting and logic issues in the code you actually touched, without scanning the hundred other files that haven’t changed.
Type-check-changed-files
TypeScript’s tsc --noEmit is too slow for the whole project once it grows beyond a few hundred files. Instead, use a tool like tsc-files that type-checks only the staged files. You run:
{
"*.{ts,tsx}": ["tsc-files --noEmit"]
}This relies on a project-level tsconfig.json but only inspects the changed files and their transitive dependencies. Completion times drop from minutes to under two seconds for most commits. I’ve kept this hook enabled on a monorepo with 2,000+ TypeScript files for over a year. The key is caching: TypeScript’s tsconfig.build.tsbuildinfo means unchanged files skip parsing on subsequent checks. For a deeper look at managing TypeScript in large repos, see Large-Scale Refactors with jscodeshift and ts-morph.
Tooling: Husky vs Lefthook vs Pre-commit (Python)
| Feature | Husky | Lefthook | pre-commit (Python) |
|---|---|---|---|
| Setup | npm install husky + npx husky init |
brew install lefthook or npm install -D lefthook |
pip install pre-commit + pre-commit install |
| Execution model | Shell script per hook | YAML config with parallel/piped tasks | YAML config, manages virtualenvs per hook |
| Parallel execution | Manual via & or external tooling |
Built-in (parallel: true) |
Sequential by default, parallel with -j flag |
| Language support | Any (shell) | Any (runs any binary) | Python-first; non-Python hooks require extra config |
| Startup overhead | ~50ms (shell) | ~50ms (Go binary) | ~500ms (Python virtualenv startup) |
| Common use case | Node/JS monorepos | Monorepos with mixed languages | Python ecosystems |
Husky remains the most common for Node projects. It’s simple—configure .husky/pre-commit as a shell script that calls npx lint-staged. The trade-off is that pure shell logic gets messy for complex workflows (parallel tasks, per-language hooks). Lefthook, written in Go, handles parallelism out of the box. In lefthook.yml you define groups:
# lefthook.yml
pre-commit:
parallel: true
commands:
format:
run: npx prettier --write {staged_files}
lint:
run: npx eslint --fix --max-warnings 0 {staged_files}
typecheck:
run: npx tsc-files --noEmitThis runs lint, format, and type check simultaneously. Better for monorepos with mixed languages. The Python pre-commit framework is popular in Python ecosystems. It manages hook environments and plugins, but the overhead of spawning a Python virtualenv for every commit adds roughly 500ms startup time. Worth it if your team already ships Python tools; for pure Node/TypeScript projects, it’s overkill.
Configuration That Avoids the 'Annoying Hook' Trap
Set fail_fast: true in Lefthook or similar concurrency limits in Husky so a failing format hook cancels the faster lint task immediately. The developer gets one clear failure message, not a cascade of errors from tasks that ran after the first one already failed. For ESLint and Prettier, skip --max-warnings and instead use --max-warnings 0—a warning is a signal you chose not to fix; allow zero or treat them as errors. This eliminates the “I have 200 warnings but only 1 error” noise that trains developers to ignore the output.
Store cached results for type checking: TypeScript’s incremental build (tsconfig.build.tsbuildinfo) means unchanged files skip parsing on the next commit. In your tsconfig.json, set "incremental": true and "tsBuildInfoFile": ".tsbuildinfo". The type-check hook then reuses that cache across commits. For monorepo setups, the caching strategy also applies to package management—see pnpm Workspaces: Filters, Catalogs, and CI Caching for how to extend this pattern to dependency resolution.
Failure Modes: What Breaks Hooks in Practice
Binary conflicts are the most common offboarding trigger. Tools like Prettier or ESLint that require a specific Node version will crash if a teammate has a different version managed by nvm. The hook either throws an error or produces different formatting. Pin the Node version via .nvmrc and add a hook check:
# .husky/pre-commit
if [ -f .nvmrc ]; then
nvm use || (echo "Node version mismatch. Run 'nvm use'." && exit 1)
fiMonorepo file globs cause another kind of pain. A lint-staged glob like *.{js,ts} accidentally matches files in node_modules or dist if not properly scoped. Explicitly scope globs to src/**/*.{js,ts,tsx,jsx} to avoid touching generated or third-party code.
Secret scanning hooks (e.g., git-secrets, truffleHog) produce too many false positives—commits with test data, sample configs, or API placeholder values get flagged. Teams disable them fast. Either tune the regex patterns aggressively or move secret scanning to the CI job, where you can afford slower analysis and false positives are less disruptive. For a deeper look at CI design that avoids these issues, see Designing GitHub Actions That Fail Fast and Explain Why.
Environment inconsistency between developer machines also breaks hooks. A teammate on Windows may have different line endings or binary paths than someone on macOS. Using Dev Containers or a reproducible environment script eliminates these discrepancies entirely—see Reproducible Dev Environments with Dev Containers and a Single Script for a setup that guarantees every hook runs in the same environment.
The One Hook That’s Not Worth Adding
Running the full test suite in pre-commit is a deal-breaker for any repo with more than 20 tests. It takes too long and prevents the quick-commit cycle that makes version control useful. I’ve seen teams try to fix this by running only tests for changed modules, but that’s still slow if the test runner has to discover modules, compile them, and bootstrap a test environment.
Instead, add a pre-push hook that runs a fast subset of tests (unit tests for changed modules) and keep the full suite for CI. This preserves the “safety net” trade-off without punishing every git commit. A minimal pre-push script:
# .husky/pre-push
npm run test:changedWhere test:changed uses a tool like jest --onlyChanged or vitest related to run tests for staged files. For commit message linting, which can also be a source of friction, check out Conventional Commits Without Hand-Written Changelogs for a pattern that adds zero latency.
Key takeaways
- Keep every pre-commit hook under 1–2 seconds. If it takes longer, it will be bypassed.
- Scope hooks to the staged diff:
lint-stagedfor formatting and linting,tsc-filesfor type checking. - Use
--max-warnings 0andfail_fastto avoid cascading noise. - Pin Node versions in
.nvmrcand check them in the hook to prevent binary conflicts. - Move slow tasks (full test suite, secret scanning) to CI or a pre-push hook.
Frequently asked questions
- Should my pre-commit hook lint the whole project or just changed files?
- The two most common setups are running hooks per-file (via lint-staged) or running them project-wide. Per-file is faster for large repos—only changed lines are checked, so a 5-file commit finishes in seconds. Project-wide hooks, especially type checking, can take minutes and get disabled immediately.
- What makes a development team disable a pre-commit hook?
- Dead hooks usually have one of two problems: they're too slow (a full test suite before commit) or they produce noisy output that doesn't map clearly to the diff you just wrote. If the hook gives you a wall of lint warnings from code you didn't touch, it gets bypassed with --no-verify inside a week.
- Should I fail the commit on warnings or only on errors?
- Only if your team is disciplined about fixing warnings. eslint --max-warnings 0 is the right call—failing on any warning forces the fix. But if you allow 50 warnings, that number creeps up, the hook produces noise, and developers start ignoring the output. Enforce severity thresholds, not arbitrary counts.


