husky vs lint-staged vs pre-commit
Git Hook Management and Staged File Linting Strategies
huskylint-stagedpre-commitSimilar Packages:

Git Hook Management and Staged File Linting Strategies

husky, lint-staged, and pre-commit are tools designed to automate code quality checks before commits are made, but they solve different parts of the problem. husky is a modern Git hook manager that allows you to easily define hooks (like pre-commit or commit-msg) in your package.json without manually editing files in the .git directory. lint-staged is a utility that runs linters specifically on the files you have staged for commit, preventing you from linting the entire codebase every time. pre-commit is a legacy multi-language framework (primarily Python-based) that manages hooks via a .pre-commit-config.yaml file; while powerful for polyglot repos, its Node.js implementation is largely considered deprecated in favor of the husky + lint-staged combination for modern JavaScript projects.

Npm Package Weekly Downloads Trend

3 Years

Github Stars Ranking

Stat Detail

Package
Downloads
Stars
Size
Issues
Publish
License
husky035,2774.04 kB1082 years agoMIT
lint-staged014,715166 kB1119 days agoMIT
pre-commit01,88135.9 kB664 months agoMIT

Git Hooks in Modern Frontend: Husky, Lint-Staged, and the Legacy Pre-Commit

Enforcing code quality before it hits the main branch is a non-negotiable part of professional frontend development. Git hooks are the mechanism that makes this possible, allowing teams to run tests, linters, and formatters automatically when a developer tries to commit code. However, not all tools for managing these hooks are created equal. Let's break down husky, lint-staged, and the legacy pre-commit package to understand how they fit into a modern workflow.

🪝 Installing Hooks: Package.json vs. Config Files

The first hurdle with Git hooks is installation. By default, Git stores hooks in the .git/hooks folder, which is not tracked by version control. This means every developer on your team has to manually set them up, which rarely happens consistently.

husky solves this by moving hook definitions into your package.json. When you install dependencies, husky automatically sets up the necessary scripts in .git/hooks to point back to your project commands. It keeps everything in one place and ensures consistency across the team.

// husky: package.json configuration
{
  "scripts": {
    "prepare": "husky install"
  },
  "devDependencies": {
    "husky": "^8.0.0"
  }
}
# husky: Adding a specific hook
npx husky add .husky/pre-commit "npm test"

pre-commit (the npm package) attempted to do something similar but relied on a separate configuration block in package.json or a dedicated config file. It required a more verbose setup and often struggled with cross-platform compatibility. More importantly, it is no longer actively maintained for the JavaScript ecosystem.

// pre-commit (legacy): package.json configuration
{
  "pre-commit": [
    "lint",
    "test"
  ],
  "scripts": {
    "lint": "eslint .",
    "test": "jest"
  }
}

lint-staged does not install hooks itself. Instead, it is designed to be called by a hook manager like husky. It focuses purely on identifying which files are staged and running commands against them.

// lint-staged: package.json configuration
{
  "lint-staged": {
    "*.js": "eslint --fix"
  }
}

🎯 Scope of Execution: Whole Project vs. Staged Files

Running a linter on an entire codebase every time you make a tiny change is slow and frustrating. This is where the distinction between a hook manager and a file filter becomes critical.

husky runs whatever command you tell it to. If you tell it to run eslint ., it will lint every single file in your project, even if you only changed one line in utils.js. This can lead to long wait times and noise from unrelated errors.

# husky: .husky/pre-commit script
# Runs on EVERY file, regardless of what was staged
npm run lint:all 

lint-staged is smart about scope. It looks at the Git staging area, identifies only the files you are about to commit, and runs your linters against just those files. This makes the commit process instant, even in massive projects.

# husky + lint-staged: .husky/pre-commit script
# Runs ONLY on staged files
npx lint-staged
// lint-staged: Advanced configuration with dynamic commands
// .lintstagedrc.js
module.exports = {
  '*.{js,jsx,ts,tsx}': (filenames) => [
    `eslint --fix ${filenames.join(' ')}`,
    `prettier --write ${filenames.join(' ')}`
  ],
  '*.css': (filenames) => [
    `stylelint --fix ${filenames.join(' ')}`
  ]
};

pre-commit (npm) generally operated on the whole project or required complex scripting to filter files. It lacked the built-in, ergonomic support for "staged-only" execution that lint-staged provides out of the box.

# pre-commit (legacy): Typically ran full scripts
# No native "staged file" filtering without custom shell scripts
npm run lint

⚙️ Configuration Style: Explicit Scripts vs. Declarative Lists

How you define your workflow matters for long-term maintainability.

husky encourages explicit shell commands. You write the exact command you want to run in a file inside the .husky/ directory. This gives you full power to chain commands, handle exit codes, or run custom scripts.

# husky: .husky/commit-msg
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"

# Custom logic to check commit message format
npx commitlint --edit $1

lint-staged uses a declarative approach, mapping file patterns to commands. This is cleaner for standard linting tasks but can get tricky if you need complex conditional logic between different file types.

// lint-staged: Simple declarative mapping
{
  "*.ts": "tsc --noEmit",
  "*.{js,css,md}": "prettier --write"
}

pre-commit (npm) used a simple list of script names. While easy to read, it offered very little flexibility for passing arguments or handling specific file contexts.

// pre-commit (legacy): Simple list
{
  "pre-commit": ["lint", "test", "build-check"]
}

🚧 Error Handling and Commit Blocking

All three tools share the same fundamental mechanism: if a command exits with a non-zero status code, the Git commit is aborted. However, their behavior when things go wrong differs slightly in terms of user feedback.

husky simply passes through the exit code. If your script fails, the commit stops. It doesn't try to fix anything for you; it just says "no."

# husky: Output on failure
> pre-commit hook failed (exit code 1)
husky - pre-commit hook exited with code 1 (error)

lint-staged adds a layer of intelligence. If a linter supports an --fix flag, lint-staged can run it automatically. If the fix works, it stages the corrected files and allows the commit to proceed. If it can't fix the issue, it blocks the commit and shows exactly which files failed.

// lint-staged: Auto-fix configuration
{
  "*.js": ["eslint --fix", "git add"]
}

pre-commit (npm) would block the commit but often provided less clear output about which specific file caused the failure, especially when running global scripts.

🌐 Real-World Scenarios

Scenario 1: A New React Project

You are starting a fresh Create React App or Vite project.

  • ✅ Best choice: husky + lint-staged
  • Why? You get immediate, fast feedback on only the files you touch. Setup takes minutes via CLI commands.
# Setup commands
npm install -D husky lint-staged
npx husky install
npx husky add .husky/pre-commit "npx lint-staged"

Scenario 2: A Large Monorepo with Mixed Languages

You have a repo with Node.js, Python, and Go code.

  • ✅ Best choice: Python-based pre-commit framework (not the npm package) OR husky for JS parts.
  • Why? The Python pre-commit framework excels at managing hooks for multiple languages in one config file. However, for the JS-specific parts, many teams still prefer husky for its tighter npm integration.
# Python pre-commit framework: .pre-commit-config.yaml
repos:
  - repo: https://github.com/pre-commit/mirrors-eslint
    rev: v8.0.0
    hooks:
      - id: eslint
        files: \.(js|ts)$
  - repo: https://github.com/psf/black
    hooks:
      - id: black
        files: \.py$

Scenario 3: Migrating an Old Project

You inherit a project using the pre-commit npm package.

  • ✅ Best choice: Migrate to husky + lint-staged.
  • Why? The pre-commit npm package is stagnant. Moving to the modern standard ensures better community support, easier debugging, and compatibility with newer Node versions.

🌱 When Not to Use These

These tools are essential for team environments, but consider skipping them if:

  • You are working alone on a prototype: The overhead of setting up hooks might slow down rapid experimentation. You can add them later.
  • Your CI pipeline is extremely strict: If your CI rejects any push that doesn't pass linting anyway, local hooks are a "nice to have" for speed, not a hard requirement. However, relying solely on CI leads to slower feedback loops.
  • You are using the pre-commit npm package: Just don't. It is obsolete. Use husky instead.

📌 Summary Table

Featurehuskylint-stagedpre-commit (npm)
Primary RoleGit Hook ManagerStaged File FilterLegacy Hook Manager
Config Location.husky/ scripts & package.jsonpackage.json or .lintstagedrcpackage.json
Execution ScopeWhatever command you defineOnly staged filesUsually whole project
Auto-Fix SupportManual (via script)Built-in (--fix + git add)Limited/Manual
Maintenance Status✅ Active & Standard✅ Active & Standard❌ Deprecated/Inactive
Best ForWiring up any git hookFast linting on changes⚠️ Avoid in new projects

💡 Final Recommendation

Think of these tools as a team rather than competitors. husky is the engine that makes Git listen to your commands, and lint-staged is the filter that ensures those commands run quickly and efficiently.

  • Need to run something on commit? Use husky.
  • Need to lint only changed files? Add lint-staged into your husky hook.
  • Thinking about using pre-commit (npm)? Stop. It's a relic of a previous era.

For 99% of modern JavaScript and TypeScript projects, the combination of husky and lint-staged is the gold standard. It provides the perfect balance of safety, speed, and developer experience, ensuring that bad code never even leaves your machine.

How to Choose: husky vs lint-staged vs pre-commit

  • husky:

    Choose husky if you need a lightweight, zero-config way to install Git hooks in a JavaScript/TypeScript project. It is the industry standard for wiring up commands in package.json to Git events, making it ideal for teams that want hooks to be part of the project dependency tree rather than global system tools.

  • lint-staged:

    Choose lint-staged if you want to run linters (like ESLint or Prettier) only on the files currently staged for commit, rather than the whole project. It is almost always used alongside husky to ensure fast feedback loops and to prevent committing code that fails linting rules, especially in large monorepos.

  • pre-commit:

    Avoid choosing the pre-commit npm package for new JavaScript projects as it is effectively deprecated and unmaintained in the Node ecosystem. While the Python-based pre-commit framework exists for multi-language repos, for pure frontend work, the husky and lint-staged duo offers better integration, faster execution, and active community support.