Running Prettier and ESLint only on staged files Jump to heading

Running Prettier and ESLint across the entire repository on every commit is slow and noisy: it re-checks thousands of untouched files and buries the real diagnostics for the handful of files you actually changed. Scoping the work to staged files collapses a multi-second full-project lint into a sub-second check of two or three files, and — critically — lets the formatter re-stage its own fixes so the commit that lands is already clean. The tool that makes this reliable is lint-staged, whose parent page covers the broader formatting-automation architecture this recipe plugs into.

When to use this approach Jump to heading

Apply this recipe when:

  • Your team already runs Prettier and ESLint in CI, and you want to catch and auto-fix the same violations at commit time so pull requests arrive pre-formatted.
  • Full-project eslint . or prettier --check . takes long enough that developers skip it locally and let CI catch style drift.
  • You want formatting fixes applied and committed automatically, without asking contributors to remember a manual git add after each fix.
  • You are standardizing local hooks with Husky and want a single, fast pre-commit gate that scopes work to what is being committed.

This recipe is not the right fit if you need to validate the entire outbound commit range rather than staged files — that belongs in a pre-push hook — or if your primary concern is blocking secrets rather than style, which needs a dedicated scanner.

The diagram below shows how lint-staged sequences its work around the staging area during a commit.

lint-staged workflow around the staging area during a commitSequence: git commit triggers the pre-commit hook, which runs lint-staged. lint-staged collects staged files, stashes unstaged changes, runs Prettier and ESLint --fix on the staged file list, re-stages the fixed files, restores the stash, then allows the commit to proceed. A task failure aborts the commit.git commitpre-commit hookcollect stagedfile liststash unstaged(partial-stage safety)run tasks on file listprettier --writeeslint --fix --max-warnings 0task fails → abort commitpassre-stage fixedfilesrestore stashunstaged hunkscommit proceedsclean, formatted

Step-by-step recipe Jump to heading

Step 1 — Install husky and lint-staged Jump to heading

Add both as dev dependencies. Husky manages the Git hook plumbing; lint-staged runs commands against the staged file list.

# npm — pin to current major versions
npm install --save-dev husky lint-staged

# pnpm / yarn equivalents:
# pnpm add -D husky lint-staged
# yarn add -D husky lint-staged

Verify both resolved to the expected majors (Husky v9+, lint-staged v15+):

# Should print the installed versions, not "not found"
npx husky --version && npx lint-staged --version

Step 2 — Initialize husky and create the pre-commit hook Jump to heading

Husky v9 installs hooks into a tracked .husky/ directory and points core.hooksPath at it. Run husky init once, then replace the generated hook body:

# Creates .husky/, sets the "prepare" script, and writes a starter pre-commit
npx husky init

The generated .husky/pre-commit needs only a single line — no shebang boilerplate is required in Husky v9:

# .husky/pre-commit
npx lint-staged

The husky init command also writes a prepare script into package.json so hooks install automatically after any npm install:

{
  "scripts": {
    "prepare": "husky"
  }
}

Verify the hook is wired and executable:

# hooksPath should resolve to .husky; the file should exist
git config core.hooksPath && cat .husky/pre-commit

For the full Husky installation model — including how prepare distributes hooks to every contributor after clone — see Local Hook Configuration with Husky.

Step 3 — Configure lint-staged glob patterns per file type Jump to heading

lint-staged maps glob patterns to the commands that run against the matching staged files. Different file types need different tools: JavaScript and TypeScript get ESLint plus Prettier; JSON, Markdown, and CSS get Prettier only. Define this either in package.json or a dedicated config file.

In package.json:

{
  "lint-staged": {
    "*.{js,jsx,ts,tsx,mjs,cjs}": [
      "eslint --fix --max-warnings 0",
      "prettier --write"
    ],
    "*.{json,md,mdx,css,scss,yml,yaml}": [
      "prettier --write"
    ]
  }
}

Or, equivalently, in a .lintstagedrc.json at the repository root — useful when you want to keep package.json lean:

{
  "*.{js,jsx,ts,tsx,mjs,cjs}": [
    "eslint --fix --max-warnings 0",
    "prettier --write"
  ],
  "*.{json,md,mdx,css,scss,yml,yaml}": [
    "prettier --write"
  ]
}

Two rules govern this mapping:

  • Order matters within a glob. ESLint runs first with --fix (it can reorder imports, remove unused vars), then Prettier normalizes the final formatting. Running Prettier last guarantees the committed file matches your Prettier config exactly.
  • Do not append file globs to the commands. lint-staged appends the matched staged file paths for you. Writing eslint --fix . would re-lint the whole project and defeat the entire purpose.

Verify lint-staged parses the config and resolves the right task list:

# --debug prints the matched files and the exact commands lint-staged will run
npx lint-staged --debug

Step 4 — Enforce zero warnings with --max-warnings 0 Jump to heading

ESLint exits 0 even when it reports warnings, so warnings silently accumulate. Passing --max-warnings 0 makes any warning a hard failure that aborts the commit, forcing the author to resolve or explicitly downgrade the rule:

{
  "lint-staged": {
    "*.{js,jsx,ts,tsx}": [
      "eslint --fix --max-warnings 0",
      "prettier --write"
    ]
  }
}

The --fix pass auto-corrects every fixable rule first; --max-warnings 0 then blocks the commit only on the violations that require a human decision. Verify the gate actually blocks by introducing a deliberate warning-level violation:

# Stage a file that trips a warn-level rule, then attempt a commit.
# Expected: the commit is rejected with a non-zero exit.
git add src/example.ts && git commit -m "test warning gate"

Step 5 — Handle partially-staged files safely Jump to heading

When you stage only some hunks of a file with git add -p, that file has both staged and unstaged changes. lint-staged handles this by stashing the unstaged portion, running tasks against the staged content, re-staging the results, then restoring your unstaged hunks. This is safe, but it has one surprising consequence: because formatters operate on whole files, Prettier and ESLint --fix rewrite the entire staged file — including lines outside the hunks you staged.

# Inspect what is staged vs. working-tree before committing a partial stage
git diff --staged --name-only   # what lint-staged will process
git diff --name-only            # unstaged changes that get stashed & restored

SAFETY WARNING: With partially-staged files, an auto-fix can rewrite lines you deliberately left unstaged, and a mid-run crash can leave changes in a temporary stash. Recover any lost work with git stash list — lint-staged keeps a backup stash during the run — and restore it via git stash apply <stash-ref>. To sidestep the surprise entirely, stage whole files (git add <file>) rather than partial hunks when a formatter is in the pipeline.

Verify the round-trip preserves your unstaged work on a partial stage:

# After a partial-stage commit, your unstaged hunks should still be present
git diff --name-only

Step 6 — Verify the end-to-end loop and re-staging Jump to heading

The payoff is automatic re-staging: lint-staged stages whatever its tasks modified, so the auto-fixed output lands in the commit without a manual git add. Prove the whole loop end to end.

# 1. Introduce a fixable formatting violation (bad spacing / quotes)
#    then stage the file
git add src/messy.ts

# 2. Commit — the pre-commit hook runs lint-staged, which formats,
#    re-stages, and lets the clean commit through
git commit -m "chore: verify lint-staged auto-fix"

# 3. Confirm the committed content is already formatted —
#    there should be NO leftover unstaged diff from the fix
git diff --name-only

A clean git diff --name-only after the commit confirms the fixes were re-staged and included, not left dangling in the working tree. If a violation is not auto-fixable, the commit aborts and the file stays staged with your original content, ready for a manual fix.

The emergency bypass exists but must be a deliberate, auditable exception:

# Skips ALL pre-commit hooks, including lint-staged
git commit --no-verify -m "hotfix: bypass formatting gate"

SAFETY WARNING: --no-verify skips lint-staged entirely, so unformatted or lint-failing code can enter history and only surface later in CI. Reserve it for genuine emergencies, and back the local gate with an equivalent CI check so a bypass is caught before merge. To undo an unformatted commit that slipped through, run git reset --soft HEAD~1, re-stage, and commit again through the hook.

Validation checklist Jump to heading

Before considering this setup production-ready, confirm each item:

Frequently asked questions Jump to heading

Do I need to run git add after lint-staged fixes a file? Jump to heading

No. lint-staged automatically re-stages any file its tasks modify. After each command in a glob’s task list exits successfully, lint-staged runs git add on the exact file list it passed to that command, so the auto-fixed output is included in the commit. Manually appending git add to your task list is unnecessary and can even interfere with lint-staged’s own staging logic.

Should I define lint-staged config in package.json or a separate file? Jump to heading

Both are equivalent. Use the lint-staged key in package.json for small, static configs. Reach for a dedicated .lintstagedrc.json when you want to keep package.json lean, or a .lintstagedrc.mjs / lint-staged.config.js when you need JavaScript logic — for example, computing commands dynamically or filtering out files that ESLint is configured to ignore so it does not error on them. The behavior at commit time is identical regardless of where the config lives.

Why does Prettier reformat lines I did not touch in a partially-staged file? Jump to heading

Because lint-staged and the formatters operate on whole files, not individual hunks. When you stage only some hunks with git add -p, lint-staged stashes the unstaged changes, formats the entire staged file, re-stages it, then restores your unstaged hunks. The formatter therefore rewrites the complete file, which can touch lines outside your staged hunks. Stage the file fully — or format it before staging — to keep the diff predictable.

  • Lint-Staged & Formatting Automation — the parent page covering the full formatting-automation architecture, task orchestration, and performance model that this recipe implements.
  • Local Hook Configuration with Husky — install and distribute the pre-commit hook automatically via the prepare lifecycle so every contributor gets it after clone.
  • Trunk-Based Development Setup — keep short-lived branches mergeable by enforcing consistent formatting at commit time, a prerequisite for a low-friction trunk-based flow.