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.

lint-staged execution pipelineSequence diagram showing how lint-staged isolates staged files, runs formatters and linters, re-stages fixed files, and either allows or blocks a commit.DeveloperGit / Huskylint-stagedFormatters / LintersIndex / Commitgit commitpre-commit hook(Husky delegates)stash unstaged(keep-index)diff --cached(build file list)run configuredtasks in parallelre-stage modifiedfiles (git add)restore stashcommit createdexit 0commit abortedexit non-zeroexit 0exit non-zero

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

  1. Add lint-staged as a dev dependency:
# Installs lint-staged; use --legacy-peer-deps only if your lock file requires it
npm install --save-dev lint-staged
  1. 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-verify to 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 .gitignore entries 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

SymptomLikely causeFix
lint-staged exits immediately with No staged files match any configured taskAll staged files match globs that do not exist in the config, or only deleted files are stagedVerify 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 errorsESLint no-eval, no-debugger, or similar rules trigger on code that Prettier re-indented and exposedFix the ESLint errors manually, re-stage, and retry the commit; do not use --no-verify
Hook hangs indefinitely on a large refactorESM/CJS interop issue in a legacy tool, or a linter stuck waiting for stdinAdd --timeout 10000 to the npx lint-staged call in .husky/pre-commit
ENOENT: no such file or directory for a formatter binarynode_modules/.bin is not on PATH inside the hook environmentUse npx lint-staged (not a bare lint-staged call) so npx resolves the binary from node_modules
Cache grows unboundedly and slows down the hookFormatter cache was committed or .gitignore entry is missingAdd .eslintcache and .prettiercache to .gitignore; delete and regenerate the cache files
Different team members see different formatter outputMultiple 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.