Lint-Staged & Formatting Automation Jump to heading
lint-staged sits inside the Git Automation & CI/CD Hook Engineering pre-commit layer: it receives the staged file list from Git, fans it out to formatters and linters, re-stages auto-fixed output, and either passes the commit through or blocks it with a non-zero exit code — all without touching files the developer did not stage.
Prerequisites Jump to heading
How lint-staged isolates staged files Jump to heading
Before writing any configuration it helps to see exactly what lint-staged does to the working tree. The diagram below traces a single git commit from the developer’s keypress through to the commit object being written — or the commit being aborted.
Internally, lint-staged computes the staged file list with git diff --cached --diff-filter=ACMR --name-only, then passes matching paths to each configured task. The --diff-filter=ACMR flag limits the set to Added, Copied, Modified, and Renamed files — it skips deleted files that have no content to lint.
Step 1 — Install lint-staged Jump to heading
- Add
lint-stagedas a dev dependency:
# Installs lint-staged; use --legacy-peer-deps only if your lock file requires it
npm install --save-dev lint-staged - Verify the installed version meets the minimum requirement:
npx lint-staged --version
# Expected: 15.2.0 or higher Step 2 — Write the task configuration Jump to heading
lint-staged reads its task map from package.json under the "lint-staged" key, or from a lint-staged.config.js file at the repository root. Each key is a glob pattern; its value is an array of commands executed in order against every matched staged file.
{
"lint-staged": {
"*.{js,ts,jsx,tsx}": [
"prettier --write",
"eslint --fix --cache --cache-location .eslintcache"
],
"*.{css,scss}": [
"prettier --write",
"stylelint --fix"
],
"*.{md,yml,yaml,json}": [
"prettier --write"
]
}
} prettier --write is idiomatic here — lint-staged automatically re-stages any file the formatter modifies, so no explicit git add is needed in the task list.
For projects that need dynamic glob construction or conditional logic, use lint-staged.config.js:
// lint-staged.config.js — use when globs depend on environment or workspace paths
export default {
"*.{ts,tsx}": [
"prettier --write",
// --quiet suppresses the "X problems" summary; errors still surface
"eslint --fix --cache --cache-location .eslintcache --quiet"
],
"*.{css,scss}": ["prettier --write", "stylelint --fix"],
"*.{md,json,yml}": ["prettier --write"]
}; Verify the configuration is parsed without errors:
# --debug prints the resolved task list without running any commands
npx lint-staged --debug --diff="" Step 3 — Wire the pre-commit hook Jump to heading
Local Hook Configuration with Husky manages hook lifecycle registration. Add lint-staged to the pre-commit hook file Husky creates:
# Append the lint-staged invocation to the Husky pre-commit hook
echo "npx lint-staged" >> .husky/pre-commit Confirm the hook file is executable and contains the correct command:
cat .husky/pre-commit
# Expected output includes: npx lint-staged
# Verify the file has execute permission
ls -l .husky/pre-commit
# Expected: -rwxr-xr-x (or similar with x bit set) Safety Warning: Never pass
--no-verifyto bypass the pre-commit hook in shared automation scripts. This suppresses all hooks — including any secrets-scanning or commit-message validation running alongside lint-staged. If a specific bypass is required for emergency hotfixes, document the authorized pattern (HUSKY=0 git commit) and restrict its use to senior maintainers.
Step 4 — Enable formatter caching Jump to heading
Formatter caches dramatically reduce execution time on large codebases. Add cache flags to every tool in your task list:
# Prettier content-based cache — skips files whose content has not changed
prettier --write --cache --cache-strategy content
# ESLint file-metadata cache — stored in .eslintcache at the project root
eslint --fix --cache --cache-location .eslintcache Add both cache files to .gitignore immediately:
# Append cache entries to .gitignore
printf '\n# formatter caches\n.eslintcache\n.prettiercache\n.stylelintcache\n' >> .gitignore Safety Warning: Untracked formatter cache files can cause lint-staged to re-run against them on the next commit cycle, corrupting the staged snapshot. Confirm
.gitignoreentries are in place before enabling caches in a team repository.
Verify caches are excluded from tracking:
git check-ignore -v .eslintcache .prettiercache
# Expected: .gitignore:<line>:.eslintcache
# .gitignore:<line>:.prettiercache Step 5 — Verify the end-to-end pipeline Jump to heading
Stage a file that contains a fixable formatting issue and attempt a commit:
# Create a file with deliberate formatting violations
echo 'const x={a:1,b:2}' > /tmp/test-lint.ts && cp /tmp/test-lint.ts src/test-lint.ts
# Stage the file
git add src/test-lint.ts
# Attempt a commit — lint-staged should auto-fix and re-stage the file
git commit -m "test: verify lint-staged pipeline"
# Confirm the committed file was reformatted
git show HEAD:src/test-lint.ts To test the blocking behaviour, introduce an unfixable ESLint error (e.g. eval('x') with no-eval enabled), stage it, and confirm the commit is aborted with the linter’s error output visible in the terminal.
Clean up the test file after verification:
git revert HEAD --no-edit
rm src/test-lint.ts Integration with adjacent tools Jump to heading
Husky — hook registration boundary Jump to heading
Local Hook Configuration with Husky owns the hook lifecycle: it determines when hooks run and ensures they are installed for every team member via the prepare npm script. lint-staged owns what runs inside the pre-commit event. The boundary is the .husky/pre-commit file: Husky writes it, and that file calls npx lint-staged.
Do not duplicate formatter calls in Husky’s hook file. Husky should only call npx lint-staged; all tool configuration belongs in the lint-staged task map.
CI/CD Pipeline — full-repository quality gate Jump to heading
CI/CD Pipeline Trigger Mapping describes how remote pipelines respond to push and pull-request events. lint-staged operates on the staged subset; CI must run the full-repository scripts:
{
"scripts": {
"format:check": "prettier --check .",
"lint:ci": "eslint . --cache --cache-strategy content --max-warnings 0",
"typecheck": "tsc --noEmit"
}
} The validated continuity is: local staged formatting → commit → CI full-repository check on PR → merge. lint-staged provides fast local feedback; CI provides the authoritative gate. Configuring path-specific CI triggers alongside lint-staged ensures neither layer redundantly validates code the other already covered.
Pre-push hooks — heavier validation Jump to heading
Pre-Push Validation Rules handle checks that are too slow or too broad for the pre-commit phase — type-checking, secrets scanning, and full test suites. Separate these deliberately: lint-staged should complete in under two seconds; the pre-push hook can tolerate a longer budget because it runs less frequently.
Monorepo configuration Jump to heading
Monorepos require workspace-aware globbing. Scope globs to each package directory so that each package’s local ESLint and Prettier configuration is resolved:
{
"lint-staged": {
"packages/api/**/*.{ts,js}": [
"prettier --write",
"eslint --fix --cache --cache-location packages/api/.eslintcache"
],
"packages/web/**/*.{ts,tsx,js}": [
"prettier --write",
"eslint --fix --cache --cache-location packages/web/.eslintcache"
],
"packages/docs/**/*.md": [
"prettier --write"
]
}
} For Turborepo or Nx workspaces, run lint-staged from the workspace root so it can build the full staged file list across all packages before dispatching to tool binaries that resolve per-package config automatically.
Safety Warning: Avoid using
../../relative paths inside per-package cache locations. Use workspace-root-relative paths (e.g.packages/api/.eslintcache) to prevent cache poisoning between packages during concurrent hook execution.
Troubleshooting Jump to heading
| Symptom | Likely cause | Fix |
|---|---|---|
lint-staged exits immediately with No staged files match any configured task | All staged files match globs that do not exist in the config, or only deleted files are staged | Verify glob patterns with npx lint-staged --debug; check that staged files have the extensions the globs target |
| Commit blocked after Prettier reformats but ESLint finds errors | ESLint no-eval, no-debugger, or similar rules trigger on code that Prettier re-indented and exposed | Fix the ESLint errors manually, re-stage, and retry the commit; do not use --no-verify |
| Hook hangs indefinitely on a large refactor | ESM/CJS interop issue in a legacy tool, or a linter stuck waiting for stdin | Add --timeout 10000 to the npx lint-staged call in .husky/pre-commit |
ENOENT: no such file or directory for a formatter binary | node_modules/.bin is not on PATH inside the hook environment | Use npx lint-staged (not a bare lint-staged call) so npx resolves the binary from node_modules |
| Cache grows unboundedly and slows down the hook | Formatter cache was committed or .gitignore entry is missing | Add .eslintcache and .prettiercache to .gitignore; delete and regenerate the cache files |
| Different team members see different formatter output | Multiple Prettier versions installed (global vs local) | Pin Prettier in devDependencies and always invoke it as npx prettier to guarantee the local version runs |
Frequently Asked Questions Jump to heading
Does lint-staged automatically re-stage files after Prettier rewrites them? Jump to heading
Yes. lint-staged stashes unstaged changes with git stash --keep-index, runs tasks against the staged snapshot, calls git add on any file a formatter modified, then pops the stash. You do not need git add in your task list.
Can I run lint-staged in a monorepo with different configs per package? Jump to heading
Yes. Scope your globs to each package directory (e.g. packages/api/**/*.ts) and store each package’s .eslintrc and .prettierrc at the package root. ESLint and Prettier resolve configuration by walking up from the file being linted, so they will pick up the correct per-package config automatically.
What happens to my unstaged changes when lint-staged runs? Jump to heading
lint-staged stashes unstaged changes before running tasks and restores them afterward — whether the tasks pass or fail. The stash and restore are transparent; your working tree state after a blocked commit will match what it was before you ran git commit.
How do I set a timeout so a slow linter does not block commits indefinitely? Jump to heading
Pass --timeout <milliseconds> to the lint-staged CLI in your hook file:
# .husky/pre-commit
npx lint-staged --timeout 10000 If any task exceeds the deadline, lint-staged exits non-zero and prints which task timed out.
Should CI run lint-staged or the full repository lint script? Jump to heading
CI should run the full-repository scripts (eslint ., prettier --check .). lint-staged only evaluates staged files, which is a local-developer concept — on CI there is no staging area. Use lint-staged for fast local feedback and CI for the authoritative, whole-repository quality gate.
Related Jump to heading
- Local Hook Configuration with Husky — manages hook registration and team-wide hook installation via the
preparelifecycle script - Migrating from Legacy Git Hooks to Husky v9 — step-by-step guide for teams moving off hand-maintained
.git/hooksscripts - Pre-Push Validation Rules — heavier validation (type-checking, secrets scanning, test suites) that belongs at push time rather than commit time
- Preventing Broken Builds with Pre-Push Hooks — patterns for scoping expensive checks to the pre-push event so commits stay fast
- CI/CD Pipeline Trigger Mapping — maps local hook events to remote pipeline triggers and explains where lint-staged’s responsibility ends and CI’s begins