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.
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.
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
- Your repository is not Node-centric. pre-commit (the Python one) manages the hook and each tool's own isolated environment across languages, which is a better fit for a polyglot repo where not everyone has npm installed
- You want one tool for hooks and tasks: lint-staged deliberately does not install a git hook, so you are always adding husky or similar, whereas lefthook is a single binary that does both
- You cannot move your Node floor. v17 requires Node 22.22.1 or newer and git 2.32.0 or newer, and refuses to run otherwise; earlier majors each raised the bar too, so upgrading this tool regularly means upgrading Node
- You only want a check that blocks the commit and never edits files. The maintainers themselves point at a small shell script for that case, and it avoids the entire stash-and-restore mechanism
- You are nervous about a tool running git stash create, git stash store, patch application and git reset around every commit on developer machines. It is well tested and prints how to recover, but on a very large repo it is slow, and an interrupted run leaves a stash somebody has to find
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-stagedlint-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 abc1234The 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
| Package | Registry | Pick it when |
|---|---|---|
| nano-staged | npm | You want the same idea with a much smaller dependency footprint and can live without the JavaScript config API and monorepo config discovery. |
| lefthook | npm | You want hook management and task running in one binary, parallel execution by default, and support for non-JavaScript teams. |
| pre-commit | PyPI | Your repo spans several languages and you want each hook to run in its own managed environment rather than out of node_modules. |