Skip to main content

husky vs lefthook vs lint-staged: Git Hooks in Node.js (2026)

·PkgPulse Team

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
# 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

Featurehuskylefthooklint-staged
Primary roleHook runnerHook runnerStaged-file filter
LanguageNode.jsGo (binary)Node.js
Parallel hooksN/A
Lint only staged files✅ ({staged_files})✅ (core feature)
Monorepo supportManual✅ First-class
Config formatShell scriptsYAMLJSON / JS
Auto re-stage fixed files✅ (stage_fixed: true)
Non-Node.js projects
Weekly downloads~5M~400K~8M

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/

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 →

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.