← All articles
DEVELOPER TOOLS Lefthook: Fast, Simple Git Hooks Without the Husky B... 2026-03-04 · 5 min read · lefthook · git-hooks · pre-commit

Lefthook: Fast, Simple Git Hooks Without the Husky Bloat

Developer Tools 2026-03-04 · 5 min read lefthook git-hooks pre-commit husky-alternative developer-tools

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.

Lefthook running parallel pre-commit hooks with colored output

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:

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.