mrkeyoor.com_
Thu 06 Aug 05:58 UTC
npmCLI & Toolingupdated 06 Aug 2026

lint-staged

lint-staged runs commands against only the files you have staged in git, which is what makes a pre-commit hook fast enough that people stop disabling it. You map glob patterns to commands ("*.ts": "eslint --fix"), it asks git for the staged file list, filters it with picomatch, and passes the matching absolute paths as arguments. Anything a task changes on disk gets staged into the same commit automatically, so formatters just work. Around that it does careful git bookkeeping: a backup stash before it starts, hiding the unstaged half of partially staged files so tasks only see what is being committed, restoring that half afterwards, and rolling everything back if a task fails. It does not install the hook itself, so it is normally paired with husky or another hook manager.

Verdict

The standard way to keep a pre-commit hook fast in a JavaScript repository, well maintained and unusually careful about not losing your work. Pair it with husky and keep the task list short; if your repo is polyglot or you want the hook manager included, look at pre-commit or lefthook instead.

API stability3/5The glob-to-command config has looked the same for years, but majors arrive often and each one moves the floor: v16 removed --shell and changed how processes are spawned, v17 requires Node 22.22.1 and git 2.32.0 and made the yaml dependency optional.
Docs4/5One long README documents every CLI flag, all config file formats, concurrency rules and the exact stashing algorithm, with a separate MIGRATION.md per major; there is no searchable docs site, so finding an answer means scrolling.
Maintenance5/5Pushed 31 July 2026 with only 6 open issues and PRs combined and releases landing regularly, which for a tool with this many users means the queue is genuinely being worked.
Ecosystem4/5About 28.3 million weekly downloads and the default recommendation in husky, simple-git-hooks and many project templates; it is a leaf tool, so there are no plugins to speak of, only the hook managers it pairs with.

Use it if

  • Running the linter or formatter over the whole repository at commit time is too slow, and you only care about the files in this commit
  • You want auto-fixing tools (prettier --write, eslint --fix, stylelint --fix) to include their fixes in the commit without anyone remembering to re-stage
  • You have a monorepo: drop a config file in each package and lint-staged matches each staged file to the closest config and runs tasks from that directory
  • You need control over ordering: an array runs commands in sequence per glob, a nested array runs them in parallel, and --concurrent limits how much runs at once
Skip it if

Setup reality

npm install --save-dev lint-staged is only half the job: nothing runs until you also install a hook manager and add a .husky/pre-commit file containing npx lint-staged. v17 refuses to start on Node older than 22.22.1 or git older than 2.32.0. The package has been pure ESM since v12, so a .lintstagedrc.js file follows your package.json type field, and in v17 the yaml dependency became optional, which means an extensionless .lintstagedrc (parsed as YAML) now needs you to install yaml yourself; renaming it to .lintstagedrc.json avoids that. Tasks receive absolute paths, so tools with relative ignore files may behave oddly until you add --relative. There is no shell: the --shell flag was removed in v16, so pipes, environment variable expansion and multi-command one-liners have to move into a script file or the JavaScript function syntax. And a command that must not receive filenames, tsc --noEmit being the classic, has to be written as a function that returns the command string.

Patterns

Wire it into a pre-commit hookinstall-with-husky

npm install --save-dev lint-staged husky
npx husky init

# .husky/pre-commit
npx lint-staged

lint-staged never installs a hook by itself. Do not pass file paths to it in the hook: it works out the staged files from git on its own.

Map globs to commandsbasic-config

// package.json
{
  "lint-staged": {
    "*.{js,jsx,ts,tsx}": "eslint --fix",
    "*.{json,md,yml}": "prettier --write"
  }
}

A glob without a slash matches on basename anywhere in the repo; add a slash ("src/**/*.ts") to match on path. Files a task rewrites are staged back into the commit automatically, so never add git add yourself.

Control task order per globsequence-and-parallel

{
  "*.ts": ["eslint --fix", "prettier --write"],
  "*.css": [["stylelint --fix", "postcss --replace"]]
}

A flat array runs in order and stops at the first failure. One extra level of nesting runs those commands in parallel against the same files, which is only safe when they do not both write.

Stop two formatters fighting over one fileavoid-overlapping-globs

// race: both may write the same .ts file
{ "*": "prettier --write", "*.ts": "eslint --fix" }

// fixed with negation plus ordering
{
  "!(*.ts)": "prettier --write",
  "*.ts": ["eslint --fix", "prettier --write"]
}

Different globs start at the same time by default, so overlapping patterns that both edit files are a genuine race. Negation patterns plus a sequenced array is the documented fix; --concurrent false is the blunt one.

Run tsc or a whole-project checkcommands-without-filenames

// lint-staged.config.js
export default {
  '**/*.ts?(x)': () => 'tsc -p tsconfig.json --noEmit',
};

Everything gets the staged file list appended unless you use the function form, and tsc with explicit files ignores your tsconfig. This runs a full type check whenever any TypeScript file is staged, which is correct but not fast.

Build the command from the file listdynamic-commands

// lint-staged.config.js
export default {
  '**/*.js?(x)': (filenames) =>
    filenames.length > 10
      ? 'eslint .'
      : `eslint ${filenames.join(" ")}`,
};

When you return a string from a function, you own the arguments: lint-staged appends nothing. Handy for switching to a whole-repo run once the staged set gets large enough that per-file startup dominates.

Exclude files a glob cannot expressfilter-files

// lint-staged.config.js
import picomatch from 'picomatch';

export default {
  '*.js': (files) => {
    const wanted = files.filter((f) => picomatch.isMatch(f, '!*test.js'));
    return wanted.length ? `eslint ${wanted.join(" ")}` : [];
  },
};

Return an empty array when nothing is left, otherwise you run eslint with no files and it lints the whole directory. Ignoring files is normally the linter's job through .eslintignore or .prettierignore; this is the escape hatch.

Give each package its own tasksmonorepo-configs

repo/
  package.json            # lint-staged + husky installed here
  packages/api/.lintstagedrc.json
  packages/web/.lintstagedrc.json

// packages/web/.lintstagedrc.json
{ "*.{ts,tsx}": "eslint --fix" }

Each staged file is matched to the nearest config, and tasks run with that config's directory as the working directory, so relative paths in tool configs resolve the way you expect. Override with --cwd if you need one shared directory.

Get type checking on the config itselftyped-config

/**
 * @filename: lint-staged.config.js
 * @type {import('lint-staged').Configuration}
 */
export default {
  '*': 'prettier --ignore-unknown --write',
};

The package ships its own types. A .ts config also works if your Node build supports type stripping, but then you need NODE_OPTIONS set for the hook, which is one more thing to explain to new contributors.

Run JavaScript instead of a commandjavascript-task

export default {
  '*.svg': {
    title: 'Optimise SVGs',
    task: async (files) => {
      for (const file of files) await optimiseSvg(file);
    },
  },
};

A task function runs in the lint-staged process, so there is no spawn overhead, but a thrown error is what fails the commit and you get no command output unless you print it.

Run it outside a commit hookci-usage

# check everything changed against the base branch, no stashing
npx lint-staged --diff="origin/main...HEAD"

# fail the build instead of quietly fixing files
npx lint-staged --fail-on-changes --continue-on-error --verbose

--diff implies --no-stash, which is what you want in CI where there is nothing to protect. --fail-on-changes turns "the formatter rewrote your file" into a red build instead of a silent amend.

Get your work back after an interrupted runrecover-after-failure

git stash list --format="%h %s"
# abc1234 On main: lint-staged automatic backup

git apply --index abc1234

The backup stash is created first and dropped last, so an interrupted run leaves it behind on purpose. lint-staged prints these exact commands when something goes wrong, which is worth knowing before it happens rather than after.

Alternatives

PackageRegistryPick it when
nano-stagednpmYou want the same idea with a much smaller dependency footprint and can live without the JavaScript config API and monorepo config discovery.
lefthooknpmYou want hook management and task running in one binary, parallel execution by default, and support for non-JavaScript teams.
pre-commitPyPIYour repo spans several languages and you want each hook to run in its own managed environment rather than out of node_modules.