Lefthook: Fast, Simple Git Hooks Without the Husky Bloat
Git hooks automate quality gates: lint before commit, run type checks before push, format code automatically. The standard Node.js ecosystem solution is Husky. It works, but it comes with tradeoffs: Node.js dependency, sequential hook execution, and setup that breaks in monorepos and non-Node projects.
Lefthook is a Git hooks manager written in Go. It ships as a single binary with no runtime dependencies, runs hooks in parallel by default, works with any language stack, and uses a YAML config file that scales cleanly from small projects to large monorepos.

Why Lefthook Over Husky
Speed: Lefthook runs hooks in parallel by default. Husky runs them sequentially. On a project with ESLint, TypeScript type-checking, and Prettier, Lefthook finishes in the time the slowest check takes — typically 3-5x faster.
No Node.js dependency: Lefthook is a Go binary. It works in Python, Rust, Go, Ruby, Java, and shell-only projects — anything that uses Git. Husky requires Node.js and npm, which excludes it from non-JS projects.
Simpler monorepo support: Lefthook handles --staged-files and glob-based file filtering natively. Running ESLint only on staged TypeScript files, or running Rust clippy only when .rs files are staged, is a single config line.
Cleaner output: Lefthook shows a clean summary of which hooks passed and which failed, with colored output that's easy to scan.
Installation
Homebrew (macOS/Linux):
brew install lefthook
npm (for Node.js projects — installs in node_modules):
npm install --save-dev @evilmartians/lefthook
Go:
go install github.com/evilmartians/lefthook@latest
Standalone binary — download from GitHub releases and put in PATH.
Initialize in a project:
cd your-project
lefthook install
This creates .git/hooks/ symlinks pointing to lefthook and generates a sample lefthook.yml if none exists.
Basic Configuration
lefthook.yml lives in your project root:
pre-commit:
parallel: true
commands:
lint:
glob: "*.{ts,tsx}"
run: npx eslint {staged_files}
typecheck:
run: npx tsc --noEmit
format:
glob: "*.{ts,tsx,js,json,md}"
run: npx prettier --check {staged_files}
pre-push:
commands:
tests:
run: npm test
Breaking this down:
parallel: true— lint, typecheck, and format run concurrentlyglob: "*.{ts,tsx}"— only runs if staged files match the pattern{staged_files}— template variable replaced with the space-separated list of staged files matching the globpre-push— runs ongit push, after all commits are ready
Template Variables
Lefthook provides several template variables for dynamic hook commands:
| Variable | Description |
|---|---|
{staged_files} |
Space-separated list of staged files (pre-commit only) |
{push_files} |
Files changed in the push (pre-push only) |
{all_files} |
All files tracked by git |
{files} |
All files matching the command's glob |
pre-commit:
commands:
format:
glob: "*.py"
run: black {staged_files} && isort {staged_files}
lint:
glob: "*.py"
run: ruff check {staged_files}
Advanced Patterns
Skip on CI
Automatically skip hooks in CI environments:
pre-commit:
skip:
- merge
- rebase
- ref: refs/heads/main # skip on main branch
commands:
lint:
run: npm run lint
Or set LEFTHOOK=0 environment variable to bypass hooks:
LEFTHOOK=0 git commit -m "emergency fix"
Run Commands in Specific Directories
pre-commit:
commands:
frontend-lint:
root: "frontend/"
glob: "*.{ts,tsx}"
run: npm run lint {staged_files}
backend-format:
root: "backend/"
glob: "*.go"
run: gofmt -l -w {staged_files}
Scripts Instead of Commands
For complex hooks, reference a script file:
pre-commit:
scripts:
"validate-commit.sh":
runner: bash
#!/bin/bash
# .lefthook/pre-commit/validate-commit.sh
if git diff --cached --name-only | grep -q "\.env"; then
echo "Error: Attempting to commit .env file"
exit 1
fi
Commit Message Validation
commit-msg:
commands:
validate:
run: |
msg=$(cat {1})
if ! echo "$msg" | grep -qE "^(feat|fix|chore|docs|refactor|test|style): .+"; then
echo "Invalid commit message format. Use: type: description"
echo "Types: feat, fix, chore, docs, refactor, test, style"
exit 1
fi
Monorepo with Glob Filtering
pre-commit:
parallel: true
commands:
packages-a-lint:
glob: "packages/a/**/*.ts"
run: cd packages/a && npx eslint {staged_files}
packages-b-lint:
glob: "packages/b/**/*.ts"
run: cd packages/b && npx eslint {staged_files}
shared-typecheck:
run: npx tsc --build tsconfig.json
Only the commands whose globs match staged files run — no unnecessary work.
Common Use Cases
Node.js/TypeScript Project
pre-commit:
parallel: true
commands:
lint:
glob: "*.{ts,tsx,js,jsx}"
run: npx eslint --fix {staged_files} && git add {staged_files}
format:
glob: "*.{ts,tsx,js,jsx,json,md,yaml}"
run: npx prettier --write {staged_files} && git add {staged_files}
typecheck:
run: npx tsc --noEmit
pre-push:
commands:
test:
run: npm test -- --passWithNoTests
Python Project (Ruff + Black)
pre-commit:
parallel: true
commands:
format:
glob: "*.py"
run: black {staged_files} && git add {staged_files}
lint:
glob: "*.py"
run: ruff check --fix {staged_files} && git add {staged_files}
typecheck:
run: mypy src/
pre-push:
commands:
tests:
run: pytest
Go Project
pre-commit:
parallel: true
commands:
format:
glob: "*.go"
run: gofmt -w {staged_files} && git add {staged_files}
lint:
glob: "*.go"
run: golangci-lint run {staged_files}
vet:
run: go vet ./...
pre-push:
commands:
test:
run: go test ./...
Lefthook vs. Alternatives
| Tool | Language | Parallel | Dependencies | Monorepo |
|---|---|---|---|---|
| Lefthook | Go (binary) | Yes | None | Excellent |
| Husky | Node.js | No | npm/Node.js | Basic |
| pre-commit | Python | Limited | Python | Good |
| lint-staged | Node.js | Yes | npm/Node.js | Good |
| Overcommit | Ruby | No | Ruby | Basic |
vs. Husky: Husky is simpler for pure Node.js projects with basic needs. Lefthook is better for parallel execution, non-Node projects, and monorepos. They can coexist if needed.
vs. pre-commit: pre-commit has a large ecosystem of pre-built hooks (over 1,000). Lefthook requires writing your own commands. pre-commit is better when you want managed, versioned hook configs; Lefthook when you want maximum flexibility.
vs. lint-staged: lint-staged is excellent at what it does — running linters on staged files. Lefthook is more general (handles all hook types, not just pre-commit) with better monorepo support.
Team Setup
For shared team configs, commit lefthook.yml to the repository. Each team member runs lefthook install after cloning:
// package.json (for Node.js projects)
{
"scripts": {
"prepare": "lefthook install"
}
}
npm install will automatically call lefthook install for new developers.
For non-Node projects, document the setup in your README:
# In your project README:
# After cloning:
brew install lefthook && lefthook install
Performance
The parallel execution difference is significant on larger projects:
# Husky (sequential):
✓ eslint (4.2s)
✓ tsc (6.8s)
✓ prettier (1.1s)
Total: 12.1s
# Lefthook (parallel):
✓ eslint + tsc + prettier
Total: 6.9s (longest individual task)
For a project running 10 lint/check commands, the difference is even larger.
Conclusion
Lefthook is the right choice for most projects beyond simple Node.js applications. The single-binary distribution, parallel execution, and clean glob-based file filtering make it faster and more versatile than Husky without meaningful tradeoffs.
Install it, write a lefthook.yml in 5 minutes, and never again merge a commit that breaks your linter — without suffering through slow sequential hook execution while you wait.