What is lint-staged?
Lint-staged is a tool that runs linters and formatters only on files that are currently staged in Git. Instead of linting your entire codebase every time you commit—which becomes painfully slow as projects grow—lint-staged intelligently filters and executes commands against only the files you've changed and staged for commit. It works hand-in-hand with tools like Husky to create a powerful pre-commit workflow that catches issues before they ever reach your repository.
At its core, lint-staged uses a simple but powerful concept: it accepts a configuration object mapping glob patterns to commands, then passes the staged files matching each pattern to the corresponding command. This means you can run ESLint only on staged .js files, Prettier on staged .json and .css files, and completely ignore untouched parts of your project.
Why lint-staged matters
🚀 Deploy your AI agent in 10 minutes
Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.
Try it free →Before lint-staged, developers commonly faced two suboptimal choices: either run linters on the entire project during a pre-commit hook (which grew slower with every new file added) or skip pre-commit linting entirely and rely on CI pipelines to catch issues (which meant feedback arrived minutes or hours after a push). lint-staged solves both problems simultaneously:
- Speed: By operating only on staged files, execution time stays consistently fast regardless of project size. A 10,000-file codebase with 2 staged files runs in roughly the same time as a 100-file codebase with 2 staged files.
- Immediate feedback: Issues are caught at commit time, before code leaves the developer's machine, creating a dramatically shorter feedback loop than waiting for CI.
- Precision: You can apply different tools to different file types—ESLint for JavaScript, Stylelint for CSS, Prettier for everything it supports—without complex conditional scripting.
- Team consistency: Combined with Husky, lint-staged enforces code quality standards automatically across every developer on a project, eliminating the "I forgot to run the linter" class of problems.
- Reduced CI costs: Catching issues locally means fewer failed CI runs, saving both compute resources and developer time spent investigating remote failures.
Installation and setup
Setting up lint-staged involves three steps: installing the package, configuring Husky (or another Git hook manager), and defining your lint-staged configuration. Here's the complete flow:
Step 1: Install dependencies
npm install --save-dev lint-staged husky
# or
yarn add -D lint-staged husky
# or
pnpm add -D lint-staged husky
Step 2: Initialize Husky
Husky manages Git hooks so you don't have to manually edit .git/hooks files. Initialize it and create a pre-commit hook:
npx husky init
This creates a .husky directory in your project root with a pre-commit file. Edit that file to run lint-staged:
# .husky/pre-commit
npx lint-staged
Now whenever you run git commit, Husky fires the pre-commit hook, which invokes lint-staged.
Step 3: Configure lint-staged
lint-staged reads its configuration from several possible locations, in order of priority:
- A
"lint-staged"key inpackage.json - A
.lintstagedrcfile (JSON or YAML) - A
.lintstagedrc.json,.lintstagedrc.yaml, or.lintstagedrc.jsfile - A
lint-staged.config.jsorlint-staged.config.mjsfile
The simplest approach is adding it directly to package.json:
{
"name": "my-project",
"version": "1.0.0",
"lint-staged": {
"*.js": ["eslint --fix", "prettier --write"],
"*.{css,scss,less}": ["stylelint --fix", "prettier --write"],
"*.{json,md,graphql}": ["prettier --write"]
}
}
For more complex configurations, a dedicated JavaScript config file offers greater flexibility:
// lint-staged.config.js
export default {
'*.js': ['eslint --fix', 'prettier --write'],
'*.{css,scss,less}': ['stylelint --fix', 'prettier --write'],
'*.{json,md,graphql}': ['prettier --write'],
};
How lint-staged works under the hood
Understanding the internal mechanics helps you write better configurations. When lint-staged runs, it performs these steps in sequence:
- Collect staged files: It runs
git diff --staged --diff-filter=ACMR --name-only -zto get the list of staged files, filtering for Added, Copied, Modified, and Renamed files. - Match against globs: Each glob pattern in your configuration is tested against the staged file list. Files that match multiple patterns are assigned to the first matching rule (order matters).
- Resolve commands: For each matched group, the command array is processed. lint-staged automatically appends the matched file paths to the end of each command, so you don't need to reference
$FILEvariables manually. - Execute sequentially: Commands within a rule run sequentially in the order defined. If any command exits with a non-zero code, the entire commit is blocked.
- Task concurrency: By default, different rules run in parallel for speed, but you can control this behavior.
The key insight is that lint-staged appends file paths to your commands automatically. When you write "eslint --fix", what actually executes is eslint --fix file1.js file2.js. This design keeps your configuration clean and declarative.
Configuration patterns and examples
Basic single-command per pattern
The simplest configuration runs one tool per file pattern:
{
"lint-staged": {
"*.js": "eslint --fix",
"*.css": "stylelint --fix",
"*": "prettier --write --ignore-unknown"
}
}
The catch-all "*" pattern at the end runs Prettier on any staged file not already handled by earlier patterns. The --ignore-unknown flag tells Prettier to silently skip files it doesn't support rather than erroring.
Multiple commands per pattern
When you need to run several tools on the same file type, use an array of commands. They execute in order:
{
"lint-staged": {
"*.{ts,tsx}": [
"eslint --fix --max-warnings=0",
"prettier --write",
"jest --bail --findRelatedTests --passWithNoTests"
]
}
}
Here, ESLint runs first and fixes what it can. If it exits successfully (zero warnings with --max-warnings=0), Prettier formats the files, then Jest runs only tests related to the changed files. If ESLint fails, the chain stops and the commit is blocked.
Using functions for complex logic
When your configuration needs conditional logic or dynamic command generation, a JavaScript config file with functions gives you full control:
// lint-staged.config.js
export default {
'*.js': (stagedFiles) => {
// Extract just the filenames without paths
const fileNames = stagedFiles.map(f => f.split('/').pop()).join(',');
console.log(`Linting ${stagedFiles.length} files: ${fileNames}`);
const commands = ['eslint --fix'];
// Only run tests if there are more than 2 files changed
if (stagedFiles.length > 2) {
commands.push('jest --bail --findRelatedTests');
}
return commands;
},
'*.{json,md}': 'prettier --write',
};
Functions receive an array of absolute file paths and can return a string, an array of strings, or even null to skip linting for that pattern. This pattern is powerful for logging, conditional test running, or integrating with custom scripts.
TypeScript-specific configuration
TypeScript projects benefit from running both ESLint and the TypeScript compiler on staged files:
{
"lint-staged": {
"*.{ts,tsx}": [
"eslint --fix",
"prettier --write",
"tsc --noEmit --pretty"
],
"*.{js,jsx}": [
"eslint --fix",
"prettier --write"
],
"*.{json,md,yml,yaml}": [
"prettier --write"
]
}
}
Note that tsc --noEmit checks types across the entire project (not just staged files) because type checking is inherently project-wide. If this is too slow for a pre-commit hook, consider moving type checking to CI and keeping lint-staged focused on faster checks. You can also use the function pattern to conditionally skip tsc:
// lint-staged.config.js
export default {
'*.{ts,tsx}': (files) => {
const commands = ['eslint --fix', 'prettier --write'];
// Only run tsc when more than 5 TS files changed
if (files.length > 5) {
commands.push('tsc --noEmit --pretty');
}
return commands;
},
};
Monorepo configuration
In monorepos using tools like Turborepo, Nx, or pnpm workspaces, lint-staged can be configured per package or globally with path-aware commands:
{
"lint-staged": {
"packages/web/**/*.{ts,tsx}": [
"eslint --fix --config packages/web/.eslintrc.js",
"prettier --write"
],
"packages/api/**/*.{ts,tsx}": [
"eslint --fix --config packages/api/.eslintrc.js",
"prettier --write"
],
"packages/shared/**/*.{ts,tsx}": [
"eslint --fix --config packages/shared/.eslintrc.js",
"prettier --write"
],
"*.{json,md,yml}": [
"prettier --write"
]
}
}
Alternatively, use a JavaScript config to dynamically resolve the correct ESLint config based on file location:
// lint-staged.config.js
import path from 'node:path';
import fs from 'node:fs';
function findEslintConfig(filePath) {
const dir = path.dirname(filePath);
const configPath = path.join(dir, '.eslintrc.js');
if (fs.existsSync(configPath)) {
return configPath;
}
// Walk up until we find a config or hit the root
const parts = dir.split(path.sep);
while (parts.length > 1) {
parts.pop();
const checkPath = path.join(parts.join(path.sep), '.eslintrc.js');
if (fs.existsSync(checkPath)) {
return checkPath;
}
}
return '.eslintrc.js'; // fallback to root
}
export default {
'**/*.{ts,tsx}': (files) => {
// Group files by their ESLint config
const groups = {};
for (const file of files) {
const config = findEslintConfig(file);
if (!groups[config]) groups[config] = [];
groups[config].push(file);
}
const commands = [];
for (const [config, groupFiles] of Object.entries(groups)) {
commands.push(`eslint --fix --config ${config} ${groupFiles.join(' ')}`);
}
commands.push('prettier --write');
return commands;
},
};
Advanced configuration options
The --diff-filter flag
By default, lint-staged considers files with statuses A (Added), C (Copied), M (Modified), and R (Renamed). You can override this with the --diff-filter CLI flag or in your configuration:
npx lint-staged --diff-filter "AM"
This restricts linting to only Added and Modified files, excluding Copied and Renamed files. Useful if you want to skip files that were merely moved.
Concurrency control
By default, lint-staged runs tasks concurrently using os.cpus().length as the max parallelism. You can override this:
npx lint-staged --concurrency 4
Or set it to 1 for sequential execution of all tasks, which can help with debugging:
npx lint-staged --concurrency 1
Stashing and unstashing
lint-staged automatically creates a Git stash of unstaged changes before running linters, then pops the stash afterward. This ensures that linters only see the staged changes and that unstaged work isn't accidentally committed. You can disable this behavior if needed:
npx lint-staged --no-stash
Use this with caution—without stashing, unstaged modifications to the same files will be included in what gets linted and potentially committed.
Debug mode
When things aren't working as expected, debug mode prints detailed information about what lint-staged is doing:
npx lint-staged --debug
This outputs the list of staged files, which glob patterns matched, the exact commands being executed, and their exit codes. Invaluable for troubleshooting configuration issues.
Partial files with --no-stash and --partial
For extremely large files where you only want to lint the changed portions, combine --no-stash with ESLint's built-in partial linting (if supported by your linter):
npx lint-staged --no-stash
Then in your ESLint config, ensure you're using eslint --fix which handles diffs efficiently. Most modern linters already operate fast enough on full files that partial linting isn't necessary.
Common pitfalls and solutions
Pitfall 1: Commands that don't accept file arguments
Some tools like tsc ignore file arguments and always run project-wide. lint-staged still appends file paths, which may cause warnings or unexpected behavior. Solutions:
- Use the function form to omit file paths:
() => 'tsc --noEmit' - Move project-wide checks to CI and keep lint-staged for file-scoped tools
// Correct approach for project-wide tools
export default {
'*.{ts,tsx}': () => 'tsc --noEmit --pretty',
// tsc ignores file arguments anyway, so the function form makes intent clear
};
Pitfall 2: Conflicting formatters
Running multiple formatters on the same files can cause conflicts. For example, ESLint's --fix and Prettier can fight over formatting rules. The standard solution is to use eslint-config-prettier to disable ESLint rules that conflict with Prettier, then run ESLint first and Prettier second:
{
"lint-staged": {
"*.js": [
"eslint --fix",
"prettier --write"
]
}
}
The order matters: ESLint fixes code quality issues, then Prettier formats consistently. With eslint-config-prettier installed, ESLint won't make formatting changes that Prettier would undo.
Pitfall 3: Slow pre-commit hooks
If lint-staged takes more than 3-5 seconds, developers will start using git commit --no-verify to bypass it. To keep it fast:
- Use ESLint's
--cacheflag to skip files that haven't changed since the last lint run - Avoid running full test suites—use
--findRelatedTestsin Jest or similar flags in other test runners - Move computationally expensive checks (type checking, security scanning) to CI
- Profile your configuration with
--debugto identify slow commands
{
"lint-staged": {
"*.js": [
"eslint --fix --cache --max-warnings=0",
"prettier --write"
]
}
}
Pitfall 4: Windows compatibility
On Windows, glob patterns and command execution can behave differently. lint-staged uses micromatch for glob matching, which is cross-platform consistent, but shell-specific syntax in commands may fail. Use the function form with execa or ensure your commands work in both PowerShell and bash environments. If your team uses Windows, test your configuration there explicitly.
Integration with popular tools
ESLint + Prettier (the classic setup)
{
"lint-staged": {
"*.{js,jsx,ts,tsx}": [
"eslint --fix --max-warnings=0",
"prettier --write"
],
"*.{json,md,css,scss,html}": [
"prettier --write"
]
}
}
This is the most common configuration. ESLint handles code quality and Prettier handles formatting. The --max-warnings=0 flag ensures even warnings block the commit.
Next.js projects
{
"lint-staged": {
"*.{js,jsx,ts,tsx}": [
"next lint --fix --file",
"prettier --write"
],
"*.{css,scss}": [
"stylelint --fix",
"prettier --write"
]
}
}
Next.js's built-in linter wraps ESLint with Next-specific rules. The --file flag tells it to lint specific files rather than the whole project.
Stylelint for CSS/SCSS
{
"lint-staged": {
"*.{css,scss,less}": [
"stylelint --fix --max-warnings=0",
"prettier --write"
]
}
}
Biome (all-in-one tool)
Biome combines linting and formatting into a single tool, simplifying your configuration dramatically:
{
"lint-staged": {
"*.{js,jsx,ts,tsx,css,json}": [
"biome check --apply --no-errors-on-unmatched"
]
}
}
The --apply flag auto-fixes both lint and format issues, and --no-errors-on-unmatched prevents Biome from erroring on files it doesn't support.
Rust-based tools (Oxlint, Ruff for Python)
For projects using the new generation of fast Rust-based linters:
{
"lint-staged": {
"*.{js,jsx,ts,tsx}": [
"oxlint --fix",
"prettier --write"
],
"*.py": [
"ruff check --fix",
"ruff format"
]
}
}
These tools are orders of magnitude faster than their JavaScript/Python counterparts, making pre-commit hooks nearly instant.
Best practices summary
- Keep it fast: Target 2-5 seconds maximum for your lint-staged configuration. Anything slower will tempt developers to bypass the hook. Profile regularly with
--debug. - Fix, don't just check: Prefer commands that auto-fix issues (
--fix,--write) over commands that only report problems. This respects developer time by automatically resolving what it can. - Fail on warnings: Use
--max-warnings=0(or equivalent) to ensure warnings block commits. Warnings ignored today become production issues tomorrow. - Order matters: Run linters before formatters. Linters may make formatting changes, and formatters should always have the final say on code style.
- Use the cache: ESLint's
--cacheflag dramatically speeds up repeated runs by skipping unchanged files. The cache is stored locally and shouldn't be committed. - Separate concerns: lint-staged is for fast, file-scoped checks. Project-wide concerns (type checking, full test suites, security scans) belong in CI.
- Version control your config: Keep lint-staged configuration in your repository so the entire team benefits from consistent pre-commit checks.
- Document escape hatches: Make sure your team knows about
git commit --no-verifyfor legitimate cases (like emergency hotfixes), but emphasize it should be the exception, not the rule. - Test on all platforms: If your team includes Windows, macOS, and Linux users, verify your configuration works identically across all three.
Complete example: A production-ready configuration
Here's a comprehensive lint-staged configuration suitable for a TypeScript monorepo with multiple packages, combining all the best practices discussed above:
// lint-staged.config.js
// A production-ready configuration for a TypeScript monorepo
const fs = require('node:fs');
const path = require('node:path');
/**
* Finds the nearest package-specific ESLint config for a file.
* Falls back to the root-level config if no package config exists.
*/
function getEslintConfigForFile(filePath) {
const dir = path.dirname(filePath);
const parts = dir.split(path.sep);
// Check each parent directory for a package-specific config
while (parts.length > 0) {
const checkDir = parts.join(path.sep);
for (const configName of ['.eslintrc.js', '.eslintrc.cjs', '.eslintrc.json']) {
const configPath = path.join(checkDir, configName);
if (fs.existsSync(configPath) && checkDir !== process.cwd()) {
return configPath;
}
}
parts.pop();
}
// Fall back to root config
return path.join(process.cwd(), '.eslintrc.js');
}
export default {
// TypeScript and JavaScript files: ESLint first, then Prettier
'**/*.{ts,tsx,js,jsx}': (stagedFiles) => {
if (stagedFiles.length === 0) return [];
// Group files by their nearest ESLint config
const groups = {};
for (const file of stagedFiles) {
const config = getEslintConfigForFile(file);
if (!groups[config]) groups[config] = [];
groups[config].push(file);
}
const commands = [];
for (const [config, files] of Object.entries(groups)) {
const fileArgs = files.map(f => `"${f}"`).join(' ');
commands.push(
`eslint --fix --cache --max-warnings=0 --config "${config}" ${fileArgs}`
);
}
// Prettier runs on all matched files regardless of grouping
const allFiles = stagedFiles.map(f => `"${f}"`).join(' ');
commands.push(`prettier --write --log-level=error ${allFiles}`);
// Only run related tests if more than 3 files changed
if (stagedFiles.length > 3) {
commands.push(
`jest --bail --findRelatedTests --passWithNoTests ${allFiles}`
);
}
return commands;
},
// Stylesheets: Stylelint then Prettier
'**/*.{css,scss,less,postcss}': [
'stylelint --fix --max-warnings=0',
'prettier --write --log-level=error',
],
// Documentation and config files: Prettier only
'**/*.{json,md,mdx,yml,yaml,graphql}': [
'prettier --write --log-level=error',
],
// HTML files
'**/*.html': [
'prettier --write --log-level=error',
],
};
This configuration demonstrates several sophisticated patterns: dynamic ESLint config resolution for monorepos, conditional test execution, proper command ordering (lint before format), the --cache flag for speed, and --max-warnings=0 for strict enforcement. The Prettier --log-level=error flag suppresses informational output, keeping the commit output clean while still showing problems.
Conclusion
lint-staged transforms the pre-commit hook from a blunt instrument into a precise, fast, and developer-friendly quality gate. By operating exclusively on staged files, it keeps execution times consistently short regardless of project size, while its flexible configuration system accommodates everything from simple single-tool setups to complex monorepo workflows with dynamic command generation. Combined with Husky for hook management, lint-staged creates an invisible safety net that catches issues before they enter your repository—without developers needing to remember which commands to run. The result is cleaner commits, fewer CI failures, and a codebase that stays consistently formatted and linted with minimal friction. Whether you're working on a solo project or coordinating a large team, investing an hour in a well-tuned lint-staged configuration pays dividends in code quality and developer experience for the entire lifetime of your project.