1.250 Linters & Static Analysis#
Linters compared: Ruff, Pylint, Flake8, Bandit and Semgrep in Python; ESLint, oxlint and Biome in JavaScript — measured, with matched rule sets.
At a glance#
What the research found
- Ruff’s lead in linting is wider than its lead in formatting. 66.7M weekly PyPI downloads against Pylint’s 10.3M and Flake8’s 9.8M, roughly three to one over the nearest; 1.253 measured the formatting comparison at about two to one against Black. — Start new Python projects on
ruff check. But the figure cannot be read as a pure linting number –ruff checkandruff formatare one package, so PyPI cannot separate them. - ESLint has not been consolidated away and is not close to it. 160.1M weekly npm downloads against oxlint’s 19.4M and Biome’s 14.3M – about five times both challengers combined, and more than Prettier’s 132.9M. — The most-installed code-quality tool in JavaScript is a linter, not a formatter. Anything replacing ESLint carries the burden of proof.
- oxlint has passed Biome on weekly downloads (19.4M vs 14.3M) while trailing it on stars (22,550 vs 25,674). — Stars measure a project’s whole life, downloads measure this week. Do not read the download lead as preference: oxlint is commonly installed alongside ESLint as a pre-filter, where Biome is pitched as a replacement for two tools.
- License is a real differentiator in linting, where 1.253 found it was not in formatting. Pylint is GPL-2.0-or-later and Semgrep LGPL-2.1-or-later; the rest are MIT or Apache-2.0. — Irrelevant to running a tool over your code in CI. Decisive if you embed one in a distributed product – which is exactly the position an IDE or developer-tools vendor is in.
- Flake8 has not released in fourteen months but carries only 23 open issues, against Pylint’s 1,081 and Ruff’s 2,154. — Read low churn as a finished scope, not abandonment. The test is whether anyone answers the tracker – Blue, which 1.253 found dead, had no release since 2022 AND no commits.
Explainer
What a linter is, and why there are ten of them#
A guide for someone who has seen npm run lint in a project and wants to know what it is
actually doing. Every term is defined before it is used.
The one-sentence version#
A linter is a program that reads your source code without running it, and prints a list of things it thinks are wrong.
The name is from lint — the fluff that comes off clothes in a dryer. The first such
program, written for C in 1978, was called lint because it picked out the fuzz. The name
stuck for the whole category.
Why this is possible at all#
Your code is text, but it is text with a strict grammar. A program can read it and build a
syntax tree — a structure that says “this is a function definition, its name is
send_email, it takes two arguments, its body contains an if statement”. Once you have
that tree, you can ask questions about it.
You can ask them without running the program, which is the whole point. Running code to find out whether it is wrong requires the bug to happen. Reading it lets you find the bug in a branch that only executes on February 29th.
Analysis that reads code without running it is called static analysis. Static means “not moving” — the code is sitting still. The opposite, dynamic analysis, means watching a program while it runs, which is what a debugger or a profiler does.
What linters catch#
Broadly four things.
Mistakes. A variable assigned and never used, usually because you renamed it in one
place and not another. A function called with three arguments where it takes two. A
comparison that is always true. Code after a return, which can never execute. These are
bugs, and they are the least controversial thing a linter says.
Suspicious patterns. Code that is legal but usually wrong — an empty except block
that swallows every error including the ones you needed to see, a loop variable captured by
a closure inside the loop. Sometimes you meant it. The linter cannot tell.
Conventions. Naming, structure, the number of arguments a function should have. These are opinions. Reasonable teams disagree, and this is the category people turn off.
Security patterns. Shapes that are commonly exploitable — building a shell command by string concatenation, using a hash function that is broken for passwords.
What a linter is not#
Three neighbours get confused with linters constantly, and the differences are the reason this survey exists.
Not a formatter#
A formatter rewrites your code to a fixed layout: where the line breaks go, how much indentation, whether strings use single or double quotes. Black and Prettier are formatters.
The difference that matters is what the output obliges you to do:
A formatter hands back your code, rewritten. You do not read the output — you accept it. There is one right answer by construction, so a formatter ends arguments.
>A linter hands back a list of complaints. Every one is a claim that needs a human decision: fix it, configure it away, or ignore it. A linter starts arguments, and that is what it is for.
This split is recent. Before formatters existed, linters enforced layout too, which is why
ESLint still ships rules called indent and semi. The modern arrangement is a formatter
for layout and a linter for correctness, and running both against the same lines causes
fights between them. Formatters are surveyed separately in 1.253.
Not a type checker#
A type is the kind of a value: a number, a piece of text, a list of users. A type checker verifies that you never use one where another is required — that you are not adding a number to a customer record.
Type checkers need to know what every value is, which means tracking types through the whole program. That is a much heavier question than a linter asks, and it catches a different class of bug. mypy and the TypeScript compiler are type checkers. They are surveyed in 1.251.
The boundary blurs in exactly one place: a few ESLint rules ask the type checker for help
— “do not await something that is not a Promise” cannot be answered from the syntax tree
alone. Those rules are the expensive ones, and they are why some linters are much slower
than others.
Not a test#
A test runs your code with known inputs and checks the output. It tells you whether the code does the right thing. A linter never runs anything and cannot tell you that — it only knows what the code looks like. Both are useful and neither substitutes for the other. Test tools are 1.254.
The two things that make linters different from each other#
Ten tools sounds like duplication. It is not, and two properties explain nearly all of it.
How much of the program can it see at once?#
Most linters read one file at a time. This makes them fast — files can be processed in parallel, and nothing has to be held in memory — and it puts a hard ceiling on what they can know. A one-file-at-a-time linter cannot tell you that a function is never called anywhere, because “anywhere” is not in front of it.
A few linters build a model of the whole program. That lets them answer real questions: is this method ever called with the wrong arguments, is this attribute ever assigned, does this class implement what it promised. Pylint does this.
It is also why Pylint is slow, and the slowness is not a defect somebody could optimize away. Looking at everything at once costs more than looking at one thing at a time. When you see a benchmark where one linter is hundreds of times faster than another, check whether they are answering the same question — usually they are not.
Can you write your own rule?#
This is the question that decides most real choices, and it is invisible in any benchmark.
Some linters let you add rules. You write a small program describing a pattern to look for, ship it as a package, and the linter loads it. Flake8 and ESLint work this way, and there are thousands of such plugins — rules for specific frameworks, for accessibility, for one company’s internal conventions.
The newer, faster linters — Ruff, Biome, oxlint — are written in Rust, a language that compiles to a machine-code binary before it runs. Their rules are compiled in. That is partly why they are fast, and it means you cannot add a rule.
For most projects this is fine: the rules you want are rules the tool already ships. For a team whose rule is “every database call must go through our wrapper”, it is decisive: no upstream project will ever ship that rule, so the tool must be extensible or it is useless for the purpose.
There is one tool that solves this differently. Semgrep lets you write a rule as a pattern that looks like the code it matches, with blanks for the parts that vary — so a rule is something any developer can write in a few minutes, rather than a plugin somebody has to build and publish.
Why the fast ones are fast#
Two reasons, and neither is cleverness.
A different language. Flake8 and ESLint are written in the same languages they analyze — Python and JavaScript, both interpreted, meaning a second program reads your program and executes it step by step. Ruff, Biome and oxlint are written in Rust and compiled to machine code the processor runs directly. That is a large constant-factor difference before any algorithm is considered.
Doing less. The fast tools mostly read one file at a time and skip the questions that need the whole program or the type checker. Some of their speed advantage is not doing the expensive work.
Both reasons are real, and a comparison should state which one it is measuring. Two linters running different sets of rules are not doing the same job, and the ratio between them measures the configuration rather than the tools.
How linters actually get run#
Three places, and most teams use all three.
In the editor, as you type, with problems underlined. This needs to be fast — a linter taking two seconds is useless on every keystroke — which is what drove the Rust rewrites.
Before a commit, via a pre-commit hook: a script git runs automatically before recording a change, which can refuse the commit. This catches things before they reach anyone else.
In CI — continuous integration, a server that checks every proposed change. This is the one that blocks a merge, and the one where slowness is measured in developer patience.
Autofix, and why “unsafe” is worth reading#
Many linters can fix some of what they find. --fix applies the fixes.
Fixes are usually classified safe or unsafe. Unsafe does not mean “probably wrong”. It means the fix could change what the program does. Removing an unused import is safe unless that import has a side effect just from being imported — and the linter cannot tell whether it does.
So --unsafe-fixes on a large codebase means accepting that class of risk in exactly the
places the tool has said it cannot see clearly. Read the diff.
The vocabulary, collected#
- Static analysis — examining code without running it.
- Syntax tree / AST (abstract syntax tree) — the structure a parser builds from source text; what a linter actually examines.
- Rule — one check. A linter is a collection of rules plus a way to run them.
- Plugin — a rule someone else wrote, loaded into a linter that supports it.
- False positive — a complaint about code that is actually fine. Unavoidable: a linter with none has too few rules.
- Autofix — a rule that can rewrite the code to resolve its own complaint.
- Baseline — a record of findings that already exist, so a tool can be adopted on an old codebase and fail only on new problems.
- SAST — Static Application Security Testing. A security-focused linter, roughly.
- Data-flow analysis — tracing where a value came from and where it goes, across functions. Much harder than pattern matching, and how you prove untrusted input reaches something dangerous.
- Taint tracking — data-flow analysis specifically for untrusted input.
If you remember three things#
- A formatter ends arguments; a linter starts them. A formatter’s output you accept without reading. Every line a linter prints is a decision you have to make.
- Speed comes from doing less, as well as from being faster. Before comparing two linters, check whether they are running the same rules and answering the same questions. Often the fast one is not attempting what the slow one is for.
- The question that decides your choice is whether your rules can be somebody else’s rules. If yes, use the fast compiled tools. If your value is in a rule nobody upstream would accept, you need one that can be extended — and no amount of speed substitutes.
S1: Rapid Discovery
S1 — Linters & Static Analysis#
A linter reads your code and complains about it. Unlike a formatter, whose output you
accept without reading, every line a linter emits is a claim that something is wrong and a
decision you have to make. That is the category, and the boundary that draws it is in
approach.md.
Every figure below was read from a primary source on 2026-08-29: the PyPI JSON API, pypistats.org last-week counts, the npm registry with api.npmjs.org counts, and the GitHub REST API.
The category, measured#
| Tool | Language | Version (date) | Weekly downloads | Stars | Open issues | License |
|---|---|---|---|---|---|---|
| Ruff | Python | 0.16.5 (2026-08-27) | 66,717,656 | 49,385 | 2,154 | MIT |
| Pylint | Python | 4.0.8 (2026-08-29) | 10,252,987 | 5,720 | 1,081 | GPL-2.0-or-later |
| Flake8 | Python | 7.3.0 (2025-06-20) | 9,819,012 | 3,823 | 23 | MIT |
| Bandit | Python | 1.9.4 (2026-02-25) | 5,595,250 | 8,246 | 259 | Apache-2.0 |
| Semgrep | multi | 1.175.0 (2026-08-26) | 5,350,625 | 16,438 | 905 | LGPL-2.1-or-later |
| Vulture | Python | 2.16 (2026-03-25) | 3,016,600 | 4,782 | 71 | MIT |
| ESLint | JS/TS | 10.9.1 (2026-08-24) | 160,134,601 | 27,489 | 137 | MIT |
| oxlint | JS/TS | 1.80.0 (2026-08-24) | 19,368,621 | 22,550 | 814 | MIT |
| Biome | JS/TS/CSS | 2.5.11 (2026-08-27) | 14,289,525 | 25,674 | 518 | Apache-2.0 |
| Stylelint | CSS | 17.14.1 (2026-07-20) | 11,480,478 | 11,516 | 136 | MIT |
Star counts are for the containing repository; oxlint’s is oxc-project/oxc.
What the numbers say before any benchmark is run#
Ruff’s lead in linting is wider than its lead in formatting. 66.7M against Pylint’s
10.3M and Flake8’s 9.8M — roughly three to one over the nearest. 1.253 measured the
formatting comparison at about two to one against Black and called it “not a rout”. In
linting it is closer to one. Caveat that must travel with the figure: PyPI counts
installs of a package, and ruff check and ruff format are one binary, so the linting
share of that 66.7M cannot be separated out.
ESLint has not been consolidated away, and is not close to it. 160.1M weekly, against oxlint’s 19.4M and Biome’s 14.3M — about five times both challengers combined, and more downloads than Prettier’s 132.9M as recorded in 1.253. The most-installed code-quality tool in JavaScript is a linter, not a formatter.
oxlint has passed Biome on downloads while trailing it on stars — 19.4M against 14.3M, 22,550 stars against 25,674. Stars accumulate over a project’s life; downloads measure this week. They are not in conflict so much as answering different questions, and oxlint’s position is complicated by its being commonly installed alongside ESLint rather than instead of it.
License is a genuine differentiator here, and it was not in 1.253. That survey found MIT across nearly the whole formatting category and concluded licensing was not worth deciding on. Linting has Pylint at GPL-2.0-or-later and Semgrep at LGPL-2.1-or-later. For running a tool over your code in CI this changes nothing. For embedding one in a product you distribute, it is the first thing to check.
Two projects are quiet, and they are quiet in different ways. Flake8 has not released in fourteen months but carries 23 open issues — a tracker somebody empties, on a tool whose problem stopped changing. Vulture is four months without a push. Neither resembles the dead tool 1.253 found in Blue, which had no release since 2022 and no commits. Low churn is what finished software looks like; the test is whether anyone is still answering.
The shape of the category#
Three postures, and every tool here is one of them:
- The fast unified pass — Ruff, Biome, oxlint. Rust, one binary, rules compiled in. Fast, and structurally unable to run a rule you wrote.
- The extensible incumbent — Flake8, ESLint. Slower, and the only ones where a house rule or a framework-specific rule set can live. This is what has protected them.
- The single-purpose specialist — Bandit, Semgrep, Vulture, Stylelint. Each answers a question the general linters do not ask: is this exploitable, does this match our own pattern, is this dead, is this CSS wrong.
The recurring decision in this category is not which tool is best. It is whether your rules can be somebody else’s rules — and that question, not speed, is what the S3 personas turn on.
S1 Approach — Linters & Static Analysis#
What this pass does#
S1 is a rapid survey of the category: what exists, what each tool is for, who maintains it, and how widely it is used. No benchmarks, no code, no verdicts that need measurement behind them. Those are S2’s job.
Every version number, release date, download figure and star count below was read from a primary source on 2026-08-29: the PyPI JSON API, pypistats.org last-week counts, the npm registry with api.npmjs.org download counts, and the GitHub REST API. Nothing is restated from memory or from another survey.
The boundary: what a linter is, and what it is not#
This category is easy to draw badly, because three kinds of tool all read source code and complain about it. The line that matters is what the output obliges you to do.
| Reads | Emits | You must | |
|---|---|---|---|
| Formatter | your code | your code, rewritten | nothing — you accept it wholesale |
| Linter | your code | a list of complaints | decide, one at a time |
| Type checker | your code and its types | type errors | fix, or annotate, or suppress |
A formatter ends arguments. Its output is deterministic, there is one right answer by construction, and nobody reviews it. Formatters are surveyed in 1.253 Code Formatting.
A linter starts arguments, and that is the point. Every finding is a claim that something is wrong, and every claim needs a human decision: fix it, configure it away, or suppress it inline. A linter with no false positives is a linter with too few rules.
A type checker is a third thing — it needs a type system, not just a grammar, and it fails on programs a linter would pass. Type checkers are 1.251, not here.
The four cases that make the boundary worth writing down#
Autofix. ruff check --fix and eslint --fix rewrite code, which looks like
formatting. They are still linting: the rule fired on a judgment about correctness, and
applying the fix is optional. A formatter has no --fix because formatting is the fix.
Import sorting. isort, and Ruff’s I rules, are implemented as lint rules but
behave like a formatter — deterministic, applied wholesale, settling an argument nobody
wants to have. They belong with the formatters, and 1.253 covers them.
Formatting rules inside linters. ESLint’s indent, semi and quotes are lint rules
that enforce layout. They are the historical conflation this category split exists to
undo: before Prettier, the linter was the formatter. ESLint 10 still ships them in
lib/rules while directing users to @stylistic.
Security scanners. Bandit and Semgrep are linters by the definition above — they read code and emit complaints requiring judgment. What differs is the kind of claim, not the mechanism. They are in scope, and the survey says where the SAST boundary falls.
Scope#
In: general-purpose linters and static analyzers for Python and JavaScript/TypeScript, including security-focused ones and cross-language rule engines.
Out: formatters (1.253), type checkers (1.251), code parsing and AST libraries (1.252 — the substrate several of these are built on), test runners (1.254), and prose linters, which read English rather than code.
Candidates carried into S2#
Ten, chosen to cover both language ecosystems and all three postures — the fast unified challenger, the configurable incumbent, and the single-purpose specialist.
Python: Ruff, Flake8, Pylint, Bandit, Semgrep, Vulture JavaScript/TypeScript: ESLint, Biome, oxlint, Stylelint
Excluded after a look: pylama (no release since 2022-08-08, unmaintained), JSHint (2022-11-11) and standard (2024-09-13) — both superseded by ESLint’s config ecosystem, and Prospector, a meta-runner that wraps Pylint and others rather than analysing anything itself.
Bandit#
What it is: a security linter for Python. It walks the AST looking for patterns that
are known-dangerous — subprocess with shell=True, yaml.load without a safe loader,
assert used for validation, hardcoded credentials, weak hash constructions. PyCQA.
Read 2026-08-29: PyPI 1.9.4, released 2026-02-25. 5,595,250 weekly downloads. PyCQA/bandit 8,246 GitHub stars, pushed 2026-08-29, 259 open issues. Apache-2.0.
What it is really doing#
Bandit is a linter with a security rule set, not a category apart. The mechanism is the same — read the AST, match patterns, emit findings a human must triage. What differs is the claim: not “this is unclear” but “this may be exploitable”.
That changes how the output is read. A style finding can be waved away by preference. A security finding needs a reason, which is why Bandit attaches both a severity and a confidence to every result. Confidence is the more useful of the two, because it tells you how likely the finding is to be a false positive, and Bandit produces a lot of those by construction: it cannot tell whether the string that looks like a password is one.
The overlap with Ruff is nearly total#
Ruff’s S prefix is a reimplementation of Bandit’s rules, and for a project already
running Ruff, turning on S gets most of this without a second tool. That is the
default and it should be said plainly.
What Bandit still has: the full rule set rather than the ported subset, the severity/confidence pair on each finding, a baseline mechanism for accepting existing findings so a legacy codebase can adopt it without a thousand-line diff, and output formats that security tooling already ingests. Where those matter is a compliance context — someone is going to ask for the report, not just the exit code.
Where the SAST boundary falls#
Bandit is pattern-matching, not data-flow analysis. It sees that subprocess was called
with a variable; it does not trace whether that variable came from an HTTP request. Real
taint tracking — source to sink across function boundaries — is what commercial SAST
sells and what Semgrep’s paid tiers offer. Bandit finds the shapes of vulnerabilities, not
proofs of them, and a clean Bandit run is not a security assessment.
First read#
If you run Ruff, enable S and see whether anything more is needed. Add Bandit when the
answer has to be a report someone signs off on, or when a baseline is needed to adopt
security linting on an existing codebase without stopping the world.
Biome (biome lint)#
What it is: formatter and linter in one Rust binary for the JavaScript ecosystem, the successor to Rome. 1.253 covers its formatter; this is the lint half.
Read 2026-08-29: npm 2.5.11, released 2026-08-27. 14,289,525 weekly downloads. biomejs/biome 25,674 GitHub stars, pushed 2026-08-29, 518 open issues. Apache-2.0 on the repository; npm publishes it as MIT OR Apache-2.0.
The pitch, and what it actually buys#
One binary replacing ESLint and Prettier, one config file, one pass. That is the same
consolidation argument Ruff makes in Python, and it is the strongest thing about Biome:
the tools do agree with each other, because they are the same program. A repo
running Prettier and ESLint needs eslint-config-prettier to stop them fighting; a repo
running Biome does not.
Biome’s lint rules are grouped by intent — correctness, suspicious, style, complexity, security, accessibility, performance — rather than by which historical tool they came from. This reads more coherently than ESLint’s flat namespace and makes “turn on the correctness rules and nothing else” a one-line decision.
Where it sits against ESLint and oxlint#
At 14.3M weekly downloads it is roughly a tenth of ESLint’s 160.1M, and now behind oxlint’s 19.4M despite leading it on stars. The consolidation thesis has not resolved in JavaScript the way it has in Python: Ruff has three times the downloads of its nearest Python competitor, while Biome has less than a tenth of ESLint’s.
The reason is the same one that protects Flake8: plugins. ESLint’s value is the ecosystem of rules nobody at ESLint wrote, and Biome reimplements popular rules rather than running them.
Where it is weak#
No plugin ecosystem in the ESLint sense. Rules are compiled in. A house rule has nowhere to live, and a framework-specific rule set exists only if Biome wrote it.
Type-aware rules are limited. The rules that ask the TypeScript compiler for a value’s real type are ESLint’s territory.
Language coverage still has holes — 1.253 recorded that HTML, Vue, Svelte and Astro support arrived after that survey was written and remains experimental and opt-in, and that YAML and Markdown are unsupported. The linting side inherits those boundaries.
First read#
The strongest option when the goal is fewer tools rather than more rules, on a JS/TS/JSON/CSS codebase with no dependence on ESLint plugins. Budget a migration as a cost taken on deliberately, not as the safe default — the download figures say the ecosystem has not treated it as one.
ESLint#
What it is: the JavaScript and TypeScript linter, and the reason “linter” and “pluggable rule engine” mean nearly the same thing in that ecosystem.
Read 2026-08-29: npm 10.9.1, released 2026-08-24. 160,134,601 npm downloads in the last week. eslint/eslint 27,489 GitHub stars, pushed 2026-08-29, 137 open issues. MIT.
The scale is the finding#
160.1M weekly downloads is not a lead, it is a different order of magnitude. Against the fast challengers in the same category — oxlint at 19.4M and Biome at 14.3M — ESLint is roughly five times both combined. It is also downloaded more often than Prettier, which 1.253 measured at 132.9M, so the most-installed JavaScript code-quality tool is a linter, not a formatter.
137 open issues against 27,489 stars is a tightly-kept tracker, and the project shipped five days before this reading. Neither the adoption nor the maintenance signal supports treating ESLint as a legacy tool.
Why nothing has displaced it#
The plugin ecosystem, and it is not close. Rules that are type-aware
(typescript-eslint), framework-aware (eslint-plugin-react, eslint-plugin-vue),
accessibility-aware (eslint-plugin-jsx-a11y), or house-specific all live as ordinary npm
packages. A team can write a rule against its own codebase’s conventions in an afternoon.
This is the same structural advantage Flake8 holds in Python and, as there, it is the one thing the Rust rewrites cannot match by getting faster. Biome and oxlint reimplement popular rules; they do not run your plugin.
The type-aware rules deserve particular note, because they are the ones with no substitute:
they ask the TypeScript compiler what a value’s type actually is, which requires a type
checker in the loop (see 1.251). A rule like “do not await a non-Promise” cannot be
answered from the syntax tree alone.
The formatting rules are still in the box#
ESLint 10 still ships indent, semi, quotes and their relatives in lib/rules, while
directing users to @stylistic for stylistic linting. This is the historical conflation
that 1.253 and this survey exist to separate: before Prettier, the linter was the
formatter, and the rules never left.
The practical guidance has been stable for years and is worth stating plainly: do not use
ESLint to format. Run a formatter for layout and ESLint for correctness. The overlap
produces fights between the two tools, which is what eslint-config-prettier exists to
turn off.
Where it is weak#
Slow relative to the Rust tools, being JavaScript analysing JavaScript. How slow is a measurement this survey owes rather than an assertion — note that 1.253’s harness measured formatters only and explicitly declined to test the “Biome 15× faster than ESLint” claim, which is a linting claim and therefore this survey’s to settle.
Flat config was a real migration. The move from .eslintrc to eslint.config.js broke
plugin compatibility for a period, and a project pinned to an old config format is pinned
to old plugins with it.
First read#
Still the default, and the burden of proof is on anything replacing it. Reach for a faster tool when lint time is measurably hurting, and expect to keep ESLint alongside it for the type-aware and framework rules the challengers do not have.
Flake8#
What it is: a wrapper that runs three tools over your code and merges their output — Pyflakes for correctness, pycodestyle for PEP 8 layout, and McCabe for cyclomatic complexity — behind one command and one config file. Under PyCQA governance.
Read 2026-08-29: PyPI 7.3.0, released 2025-06-20 — no release in fourteen months. 9,819,012 weekly downloads. PyCQA/flake8 3,823 GitHub stars, repository pushed 2026-08-17, 23 open issues. MIT per PyPI; GitHub reports NOASSERTION, which reflects an unrecognised license file rather than an unlicensed project.
Its two main components ship on the same cadence: pyflakes 3.4.0 and pycodestyle 2.14.0, both released 2025-06-20, at 11.4M and 15.2M weekly downloads. Those figures run higher than Flake8’s own because they are transitive dependencies — Ruff’s existence has not displaced them from the dependency graphs they already sit in.
Reading the maintenance signal correctly#
Fourteen months without a release looks like abandonment and is not. 23 open issues is the number that says so: for a project with ten million weekly downloads, that is an issue tracker somebody empties, not one nobody reads. Compare Pylint’s 1,081 and Ruff’s 2,154 — both healthy projects, but ones whose scope is still moving.
Flake8’s scope is not moving. It wraps three tools that are themselves finished, and it has done that job the same way for years. It is stable, not stalled: low churn is what a tool looks like when its problem stops changing. That is a different risk profile from Blue in 1.253, which had no release since 2022 and no commits.
The distinction matters for a decision. Flake8 is not a project you should expect new capability from. It is one you can expect to keep working.
What it is for#
The plugin ecosystem, which is the reason Flake8 outlived being merely a wrapper. Plugins
are ordinary Python packages exposing an entry point, which means a team can write and
ship a house rule without persuading anyone upstream. flake8-bugbear, flake8-comprehensions
and their many siblings all live here, and Ruff’s B and C4 prefixes are reimplementations
of exactly these.
That extensibility is the one thing Ruff structurally cannot match: Ruff’s rules are compiled into a Rust binary. A team with a custom Flake8 plugin has a real reason to stay that has nothing to do with speed or inertia.
Where it is weak#
Slow, by an order of magnitude or more. It is Python analysing Python, three tools in sequence. 1.253 measured Ruff at 16–32× Black on formatting; the linting gap has not been measured here and is S2’s job, but the architectural reason for it is the same.
Configuration lives outside pyproject.toml. Flake8 has declined to read it, so a
project needs setup.cfg or .flake8 alongside the pyproject.toml everything else
uses. Minor, and a recurring irritation.
No autofix. Flake8 reports; it never rewrites. For rules that have an obvious mechanical fix, that is work Ruff does for you.
First read#
Keep it for a custom plugin, or for a codebase where a migration’s rule-by-rule diff is not worth anybody’s afternoon. Do not start here in 2026, and do not read the release gap as a reason to leave in a hurry.
oxlint#
What it is: the linter from the Oxc project — a JavaScript/TypeScript toolchain written in Rust. Positioned as a fast first pass that runs the common correctness rules, explicitly not as a full ESLint replacement.
Read 2026-08-29: npm 1.80.0, released 2026-08-24. 19,368,621 weekly downloads. oxc-project/oxc 22,550 GitHub stars, pushed 2026-08-29, 814 open issues. MIT.
The finding: it has passed Biome on downloads#
19.4M against Biome’s 14.3M. That inverts the star counts — Biome has 25,674 to Oxc’s 22,550 — and it is the kind of divergence worth stating carefully rather than over-reading.
Stars accumulate over a project’s whole life and never decay; weekly downloads measure what CI installed this week. They answer different questions, and when they disagree the download figure is the more current one. What it does not establish is preference: oxlint is frequently installed as a fast pre-filter alongside ESLint, whereas Biome is pitched as a formatter-and-linter replacing two tools. A download is not a defection.
oxlint has achieved distribution unusually quickly, and that its position relative to Biome is not the simple ranking the numbers first suggest.
The scoping decision is the interesting part#
oxlint does not attempt every ESLint rule. It implements the ones that are cheap to check from the syntax tree and skips the type-aware ones, because those need the TypeScript compiler and therefore cost what ESLint costs.
That is a coherent product decision rather than an unfinished feature, and it tells you how to use the tool: as a fast gate that catches the common mistakes on every save, with ESLint still running for the rules that need type information. Its version number — 1.80.0 in a 1.x line that ships constantly — reflects a project adding rules at a rate that makes any coverage claim stale quickly.
Where it is weak#
No type-aware rules, by design. Anything requiring the type checker is out of reach,
and that is a large and valuable fraction of what typescript-eslint provides.
Rule coverage is a moving target. Any statement about which ESLint rules oxlint implements is true for a version and a week. Check against the version you are pinning.
It does not replace your plugins. House rules and framework plugins stay on ESLint.
First read#
A fast pre-filter, not a replacement. The right question is not “oxlint or ESLint” but “is my lint pass slow enough that a two-stage setup is worth the configuration”. Note that a two-stage setup means two rule configurations to keep in agreement, which is a real ongoing cost that the speed number does not show.
Pylint#
What it is: the thorough one. A whole-program static analyzer for Python that builds
an inference model of the code — via astroid, its own AST layer — and uses it to catch
errors the grammar-level linters cannot see.
Read 2026-08-29: PyPI 4.0.8, released 2026-08-29 — the day this was read. 10,252,987 weekly downloads. pylint-dev/pylint 5,720 GitHub stars, pushed 2026-08-29, 1,081 open issues. GPL-2.0-or-later.
The license is a real differentiator, and it is the only one here#
1.253 found that licensing did not distinguish the formatters at all — MIT across almost the whole category, with the note that it was therefore not worth deciding on. Linters are not like that:
| Pylint | GPL-2.0-or-later |
| Semgrep | LGPL-2.1-or-later |
| Bandit | Apache-2.0 |
| Biome | Apache-2.0 (repo); MIT OR Apache-2.0 (npm) |
| Ruff, Flake8, ESLint, oxlint, Stylelint, Vulture | MIT |
For the ordinary case — installing a tool and running it over your source in CI — this changes nothing. Running a GPL program on your code does not make your code a derivative work, and no obligation attaches to the output.
It changes something in one specific case: embedding. If you are building a product that links or vendors the analyzer, distributing the result puts you inside GPL-2.0’s obligations, and LGPL’s are lighter but not absent. That is a narrow scenario, and it is exactly the scenario a developer-tools company or an IDE vendor is in. Anyone in it should read the license before the benchmark.
What it catches that the fast tools do not#
Pylint’s inference lets it reason across a whole module and beyond: a method called with the wrong number of arguments, an attribute that no branch ever assigns, a name imported and never used through a re-export, a class that does not implement the abstract method it claims. Grammar-level linters do not attempt these, because answering them requires resolving what a name actually refers to.
It also emits design and convention checks nothing else does — too many arguments, too many branches, inconsistent return statements. These are the ones teams most often turn off wholesale, and the reason Pylint has a reputation for noise.
Where it is weak#
Slow, and slow for a defensible reason: inference costs what it costs. This is not an implementation defect that a Rust rewrite fixes, which is why Ruff has reimplemented the cheap Pylint checks and not the expensive ones.
Noisy by default. Out of the box Pylint says a great deal, much of it about style preferences that a formatter has already settled. Teams that adopt it and never configure it tend to conclude the tool is wrong rather than the configuration is missing.
1,081 open issues against 5,720 stars is a wide tracker. Read alongside a release the same day, that reads as a broad and actively-worked scope rather than neglect — but it is a different maintenance posture from Flake8’s 23.
First read#
The complement, not the competitor. Ruff for the fast pass on every save and every commit; Pylint for the deeper checks, often on a slower CI job or a narrower path selection. The teams that get value from it are the ones that configured it; the teams that abandoned it are usually the ones that did not.
S1 Recommendation — Linters & Static Analysis#
First read of the category, from published evidence only. Nothing here rests on a benchmark; the speed questions are handed to S2 with a measurement plan.
Python#
Start with Ruff. One binary covering the Flake8 rule families, the popular plugins, isort and much of Bandit, at three times the downloads of the nearest alternative. For a new project this is the default and the alternatives need a reason.
Add Pylint when you want the checks Ruff structurally does not do. Pylint infers across a whole program — wrong argument counts, attributes never assigned, unimplemented abstract methods. Those cost inference time, which is why the Rust rewrite reimplemented the cheap Pylint checks and not the expensive ones. Ruff on every save; Pylint on a slower job. They are complements, and treating them as rivals gets the pairing wrong.
Keep Flake8 for one reason only: a custom plugin. Ruff’s rules are compiled into a binary, so a house rule written as a Flake8 plugin has nowhere to go. That is a real blocker rather than inertia. Absent a plugin, migrate — and do not read the fourteen-month release gap as urgency, because 23 open issues says the project is being kept, not abandoned.
Security: enable Ruff’s S prefix first. It is a port of Bandit’s rules and is free
if you already run Ruff. Add Bandit proper when the output has to be a report someone signs
off on, or when a baseline is needed to adopt security linting on a large existing codebase.
Vulture only during a deliberate cleanup. It answers a whole-program question nothing else asks, and it is a heuristic that framework magic defeats. A list of candidates for a human, never a CI gate.
JavaScript / TypeScript#
Keep ESLint. 160.1M weekly downloads, 137 open issues, shipped five days before this reading. The burden of proof is on anything replacing it, and the type-aware rules — which ask the TypeScript compiler what a value actually is — have no substitute in the faster tools.
Add oxlint as a pre-filter if lint time actually hurts. It runs the cheap rules fast and skips the type-aware ones by design. Note the cost the speed number hides: two tools means two rule configurations that have to keep agreeing.
Choose Biome when the goal is fewer tools rather than more rules. Formatter and linter in one binary that cannot disagree with itself is a genuine simplification for a JS/TS/JSON/CSS project with no plugin dependence. At a tenth of ESLint’s downloads it is a migration you take on deliberately, not the safe path.
Stylelint if the CSS is real — especially SCSS, Less or CSS-in-JS. Check first whether Biome already covers it.
Cross-cutting#
Semgrep when the rule you need is about your own codebase. It is the only tool here where writing a custom rule does not require learning an AST API — the pattern looks like the code. Understand the boundary before adopting: open-source Semgrep matches patterns within a file, and cross-function taint tracking is a paid tier.
Do not lint for formatting. Run a formatter (1.253) for layout and a linter for
correctness. ESLint still ships indent, semi and quotes; Stylelint removed its
stylistic rules in v15 and told users to use a formatter. Stylelint’s decision is the one
to copy.
What S1 cannot settle, and S2 must#
- How much faster the Rust linters actually are. Every speed claim in this category is a vendor number or a repetition of one. 1.253’s harness measured formatters only and explicitly parked “Biome 15× faster than ESLint” as out of scope, because it is a linting claim. It is this survey’s to settle, and the harness — container, pinned corpus, two architectures, median of nine — already exists to extend.
- Whether Astral’s
10-100×holds for the linting half. 1.253 measured the formatting half at 16.1–32.3× depending on architecture. The linter half is untested. - Rule-coverage overlap, which decides migrations: what fraction of a real Flake8 or ESLint configuration survives a move to Ruff or Biome, counted rather than asserted.
- Whether the ratio depends on architecture at all. 1.253 reported that the ruff-vs-black ratio is roughly twice as large on ARM as on x86, and withdrew it on 2026-08-29 — its ARM cells came from a contended machine, and the harness times each tool in its own window, so a machine that got busier between two windows biases the ratio between them. The question is open in both surveys rather than answered in one, and a clean ARM cell is outstanding work here for the same reason it is there.
Ruff (ruff check)#
What it is: a linter for Python written in Rust, from Astral. It reimplements the rules of Flake8, its plugin ecosystem, isort, pyupgrade, pydocstyle, Bandit and others as a single binary with no Python dependency of its own.
Read 2026-08-29: PyPI 0.16.5, released 2026-08-27. 66,717,656 PyPI downloads in the last week (pypistats.org). astral-sh/ruff 49,385 GitHub stars, repository pushed 2026-08-29, 2,154 open issues. MIT.
What it does#
Ruff’s pitch is consolidation, not just speed. A conventional Python lint setup is
Flake8 plus a handful of plugins, plus isort, plus pyupgrade, each a separate process
with its own config file and its own pass over the source. Ruff is one binary, one config
block in pyproject.toml, and one pass.
The rule set is organized by the tool each family came from, and the prefixes are the
tools’ own names: E/W from pycodestyle, F from Pyflakes, I from isort, B from
flake8-bugbear, S from Bandit, UP from pyupgrade, D from pydocstyle. Selecting
rules means selecting prefixes. This is a deliberate migration affordance — an existing
Flake8 config maps onto Ruff prefixes nearly mechanically.
ruff check --fix applies autofixes for the subset of rules that have one. Fixes are
classified as safe or unsafe, and unsafe ones are opt-in, which matters because an
“unsafe” fix here means one that can change runtime behavior rather than one that is
likely to be wrong.
Adoption#
66.7M weekly downloads is roughly three times Pylint’s 10.3M and nearly seven times Flake8’s 9.8M. That is a wider margin than Ruff holds in formatting, where 1.253 measured it at about two to one against Black.
The comparison needs a caveat this survey should not paper over: that 66.7M is one
number covering both ruff check and ruff format. PyPI counts package installs, and
the package is the same binary either way. There is no registry-level way to separate a
project that lints with Ruff from one that only formats with it, so the linting share of
that figure is unknown. What the number does establish is that Ruff is installed far more
often than either incumbent linter.
Where it is weak#
Still pre-1.0, four years in — 0.16.5 at the time of reading. A minor bump can add rules, and added rules mean new findings in CI on code that passed yesterday. Pin the exact version; the version contract promises nothing else.
Rule coverage is not complete. Ruff implements a large fraction of the Flake8 plugin ecosystem but not all of it, and Pylint’s more expensive whole-program checks — the ones that need to see more than one file at a time — are only partly reimplemented. A project leaning on a specific plugin should check that rule exists before migrating.
No custom rules in the plugin sense. Flake8 and ESLint both let you ship a plugin written in the host language. Ruff’s rules are compiled into the binary, so a house rule that nobody upstreamed has nowhere to live. This is the single most common reason a team cannot leave Flake8 or Pylint, and it is a structural consequence of the Rust rewrite rather than a gap someone forgot to fill.
First read#
The default choice for a new Python project, and the default migration target for an existing Flake8 + isort stack. The reasons to keep something else are specific and checkable: a custom plugin, a Pylint check Ruff has not reimplemented, or a policy against pre-1.0 dependencies in CI.
Semgrep#
What it is: a rule engine rather than a fixed rule set. Rules are written as code
patterns that look like the code they match, with metavariables and ellipses standing in
for the parts that vary, so a rule for “requests.get called without a timeout” reads
roughly like that call with holes in it. Multi-language.
Read 2026-08-29: PyPI 1.175.0, released 2026-08-26. 5,350,625 weekly downloads. semgrep/semgrep 16,438 GitHub stars, pushed 2026-08-28, 905 open issues. LGPL-2.1-or-later.
What makes it different from everything else here#
Every other tool in this survey ships rules somebody else decided on and lets you turn them off. Semgrep’s premise is that the valuable rules are the ones specific to your codebase — your deprecated internal helper, your auth wrapper that must not be bypassed, your convention that all database calls go through one module — and that nobody upstream can write those.
Writing an ESLint or Flake8 plugin to enforce a house convention means learning an AST API and shipping a package. Writing a Semgrep rule means writing the pattern in the language you already use, in YAML. That is a large difference in who can author a rule: it moves custom static analysis from a tooling specialist to any developer on the team.
It is also the only multi-language tool here, which matters for a polyglot repo where per-language linters mean per-language rule sets that drift.
The commercial boundary, stated plainly#
Semgrep is an open-core product. The CLI and the community rule registry are open source under LGPL-2.1; cross-file and cross-function data-flow analysis — real taint tracking from source to sink — sits in the paid tiers, along with the managed platform.
This is not a criticism, but it is the fact that decides whether the free tool solves your problem. Open-source Semgrep matches patterns within a file. If the requirement is “prove no user input reaches this sink anywhere in the repo”, that is a purchase.
The LGPL is worth a note for the same narrow reason Pylint’s GPL is: it constrains embedding and redistribution, not ordinary use in CI.
Where it is weak#
It is not a default linter. Out of the box it does not tell you your line is too long or your import is unused — the community registry has broad rule sets, but the tool earns its place through rules you write.
Slower than the single-language tools, and doing more; comparing its wall time to Ruff’s is comparing different jobs.
First read#
The right answer to “we keep making this specific mistake and no linter catches it”. Not a replacement for Ruff or ESLint — a second, narrower tool with a rule set nobody else could have written for you.
Stylelint#
What it is: the linter for CSS and its preprocessor dialects — SCSS, Less, and CSS inside styled-components or template literals. It is in this survey because CSS is code that goes wrong, and none of the JavaScript linters read it.
Read 2026-08-29: npm 17.14.1, released 2026-07-20. 11,480,478 weekly downloads. stylelint/stylelint 11,516 GitHub stars, pushed 2026-08-27, 136 open issues. MIT.
What it catches#
The failures CSS has that JavaScript does not: a shorthand property that silently
overrides a longhand declared above it, a duplicate selector two hundred lines apart,
a !important that should not be there, a color written three different ways in three
files, a media query that can never match, a unit that is invalid for its property.
None of these are syntax errors. CSS mostly does not have syntax errors — it discards what it cannot parse and carries on, which is precisely why a linter earns its place. A typo’d property name is not an error, it is silence.
The formatter split applies here too#
Stylelint used to ship stylistic rules — indentation, spacing around braces — and removed them in version 15, directing users to a formatter instead. That is the same boundary this survey is drawn on, enacted by the project itself: Stylelint decided it was a linter and stopped trying to be a formatter.
It is a useful precedent to cite, because it is the clearest case of a mature project
concluding that the two jobs are different. ESLint has not gone as far; its formatting
rules are still in lib/rules.
Where it sits#
11.5M weekly downloads puts it close to Biome’s 14.3M, and Biome does lint CSS — so for a JS/TS/CSS project already consolidating on Biome, Stylelint may be redundant. For anything using SCSS or Less, or CSS-in-JS, Stylelint’s dialect coverage is the deciding factor.
Where it is weak#
Configuration is a starting cost. The default rule set is small on purpose; most value
comes from a shared config such as stylelint-config-standard, and choosing one is a
decision the tool does not make for you.
It is JavaScript, and unhurried by Rust standards — rarely a complaint, since stylesheets are smaller than source trees.
First read#
Include it if the project has meaningful CSS that no other tool reads, and particularly if that CSS is SCSS or Less. Check first whether Biome already covers what you need.
Vulture#
What it is: a dead-code finder for Python. It collects every name a codebase defines and every name it uses, and reports the difference — functions, classes, variables, imports and attributes that nothing appears to reference.
Read 2026-08-29: PyPI 2.16, released 2026-03-25. 3,016,600 weekly downloads. jendrikseipp/vulture 4,782 GitHub stars, repository pushed 2026-04-30, 71 open issues. MIT.
Why a single-purpose tool survives here#
Unused imports and unused local variables are caught by everything — Pyflakes has done
it for years and Ruff’s F rules inherit it. What nothing else attempts is the
whole-program question: is this public function, defined in one module, called from
anywhere in the project at all?
That question cannot be answered from one file, which is why the fast per-file linters do not ask it. Vulture answers it by construction, and it is the only tool in this survey whose entire job is deletion.
It is a heuristic, and says so#
Python’s dynamism makes the question undecidable in general. A method reached by
getattr, a class instantiated from a config string, a plugin registered by entry point,
a fixture pytest finds by name — all look dead to a static reader and are not. Vulture
assigns each finding a confidence percentage and supports a whitelist file for the
known false positives.
The correct posture follows from that: Vulture generates a list of candidates for a human to check, not a list of things to delete. Used that way it earns its place on a codebase that has accumulated years of half-removed features. Used as a CI gate it will fail builds over framework magic.
Maintenance#
Four months since the last repository push at time of reading, and five since the last release. For a tool this small and this stable that is unremarkable rather than alarming — 71 open issues on 4,782 stars is a tracker in hand — but it is the quietest project in this survey and worth a re-check before adopting.
First read#
Reach for it during a deliberate cleanup, not on every commit. Expect to write a whitelist. Treat every finding as a question rather than an instruction, particularly in a codebase using a framework that resolves names at runtime.
S2: Comprehensive
S2 Approach — Linters & Static Analysis#
S1 asked what exists. S2 asks how these things work, because in this category the architecture predicts the trade-off almost perfectly: what a linter can see is a function of what it parses and how much of the program it holds in memory at once, and what it costs follows from the same fact.
The three questions S2 answers#
1. What does the tool actually parse, and how much of the program can it see at once? This single property explains most of the category. A tool that examines one file at a time can be fast and parallel and will never tell you that a public function is called nowhere. A tool that builds an inference model of the whole program can answer that, and pays for it in seconds. Neither is better; they are different questions, and a survey that ranks them on speed alone has missed what the slow one is for.
2. Where can a rule come from? Rules compiled into a Rust binary cannot be extended by you. Rules loaded as plugins can. This is the structural reason Flake8 and ESLint have survived being slow, and it is not a gap the fast tools can close by getting faster.
3. How fast, on a stated workload, with a stated rule set? Measured, not cited. The
plan is measurement-plan.md, written before any of this per Step 3.5, and the results
are performance-benchmarks.md.
What this pass does not do#
It does not rank the tools. The S1 finding — that the category splits by posture rather than by quality — holds here, and the personas in S3 are where a recommendation becomes possible, because a recommendation needs to know whose rules you are running.
It does not measure finding quality. Whether a tool’s complaints are correct is the more important question and is not a wall-clock one. No benchmark here answers it, and saying so is better than implying otherwise.
It does not test type-aware rules. Those require the TypeScript compiler in the loop,
which makes them a measurement of tsc. That belongs to 1.251.
Method for the measured section#
Inherited wholesale from 1.253’s harness, deliberately, so the two surveys’ numbers can be read against each other:
- pinned immutable corpus —
requests 2.32.5,jinja2 3.1.6,click 8.1.8;express 4.21.2andaxios 1.7.9(lib/only) - fresh copy of the corpus per tool
- caches off — a warm cache is a real number for a developer and the wrong one for a comparison, because it measures the cache
- first pass discarded, so timed runs are steady state
- median of nine. 1.253 found that at three repetitions two identical container runs disagreed by 20%
- one benchmark at a time; concurrent runs contend for the same cores
Added for linters, and the part formatters did not need: matched rule sets. Ruff runs
--select E,W,F,C90 to match Flake8’s default of pycodestyle plus Pyflakes plus McCabe.
The JavaScript trio share an intersection config. Pylint is timed alone and given no ratio
at all, because nothing else does what it does.
Full method, including why the corpus is not Astral’s, is in measurement-plan.md.
Configuration, side by side#
The minimal real configuration for each tool. This is where the architectural differences
from feature-comparison.md become visible: what a config file can express is bounded by
how the tool works.
Ruff — one block, in the file everything else uses#
# pyproject.toml
[tool.ruff]
line-length = 88
target-version = "py312"
[tool.ruff.lint]
# Prefixes are named after the tools the rules came from, which is what makes a
# Flake8 migration close to mechanical.
select = [
"E", "W", # pycodestyle
"F", # Pyflakes
"C90", # McCabe complexity
"I", # isort — import sorting
"B", # flake8-bugbear
"S", # Bandit — enable this on day one; it is free if Ruff is installed
"UP", # pyupgrade
]
ignore = ["E501"] # line length is the formatter's job
[tool.ruff.lint.per-file-ignores]
"tests/*" = ["S101"] # assert is fine in tests; S101 flags it elsewhereNote what is absent: any way to point at a rule you wrote. There is no such key, because there is no plugin interface.
Flake8 — a separate file, and the plugins that block a migration#
; setup.cfg — Flake8 does not read pyproject.toml
[flake8]
max-line-length = 88
max-complexity = 10
extend-ignore = E203, E501
per-file-ignores =
tests/*:S101# requirements-dev.txt — THIS is the part that decides whether you can migrate
flake8==7.3.0
flake8-bugbear==24.12.12 # → Ruff "B"
flake8-comprehensions==3.16.0 # → Ruff "C4"
acme-internal-lint==1.4.0 # → nothing. No Ruff equivalent exists or can.The last line is the audit S3’s migration persona is told to do first.
Pylint — the configuration it needs and rarely gets#
# .pylintrc
[MAIN]
jobs = 0 # 0 = one process per core
[MESSAGES CONTROL]
# Pylint out of the box is not a configuration anyone should run. Disabling the
# convention and refactor families leaves the checks nothing else can do: the
# inference-based ones.
disable =
missing-docstring,
invalid-name,
too-few-public-methods,
line-too-long, # the formatter's job
[DESIGN]
max-args = 8
max-branches = 15ESLint — flat config, and the boundary with the formatter#
// eslint.config.js
import js from "@eslint/js";
import prettier from "eslint-config-prettier";
export default [
js.configs.recommended,
// Turns OFF every ESLint rule that fights the formatter. Its whole job is to
// stop two tools rewriting the same lines to different targets.
prettier,
{
files: ["**/*.js"],
rules: {
"no-unused-vars": "error",
"no-console": "warn",
},
},
];Biome — one file for both jobs, which is the point#
{
"$schema": "https://biomejs.dev/schemas/2.5.11/schema.json",
"formatter": { "enabled": true, "indentStyle": "space", "indentWidth": 2 },
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"correctness": { "noUnusedVariables": "error" },
"suspicious": { "noExplicitAny": "warn" }
}
}
}Rules are grouped by intent — correctness, suspicious, style, complexity — rather than by which historical tool they came from. Easier to read, and harder to map back onto an ESLint config if you ever leave.
There is no eslint-config-prettier equivalent here and none is needed: the formatter and
the linter are the same program and cannot disagree.
Semgrep — the rule is the pattern#
rules:
- id: no-requests-without-timeout
languages: [python]
severity: WARNING
message: >-
requests call without a timeout. A hung connection holds a worker forever.
pattern-either:
- pattern: requests.get(...)
- pattern: requests.post(...)
pattern-not:
- pattern: requests.get(..., timeout=$T, ...)
- pattern: requests.post(..., timeout=$T, ...)This is the file that explains the tool. ... stands for “any arguments” and $T for any
value. The rule looks like the code it matches, so writing one needs no AST API and no
package — which is why S3’s house-rules persona ends here rather than at a Flake8 plugin.
The equivalent Flake8 plugin is a Python package with a visitor class, an entry point and a release process. Same rule, a different order of effort, and a different answer to who on the team can write one.
What running them looks like#
ruff check . # exits 1 if it finds anything
ruff check --fix . # applies SAFE fixes only
ruff check --fix --unsafe-fixes . # also fixes that can change behavior — read the diff
flake8 . # reports; never rewrites
pylint --recursive=y src/ # exit code is a BITMASK, not a count
eslint . # exits 1 on error
biome lint .
oxlint .
semgrep --config rules/ .Pylint’s exit code is worth knowing about before it surprises a CI script: it packs fatal (1), error (2), warning (4), refactor (8) and convention (16) into one integer, so a healthy run can exit 20.
ESLint — architecture#
The rule engine is the product#
ESLint parses to ESTree — a standardized JavaScript AST shape — and then hands each node to whatever rules are registered. A rule is a JavaScript object with visitor callbacks keyed by node type. That is the entire extension model, and it is deliberately small: anyone who can write JavaScript can write a rule, and it loads as an ordinary npm package.
The parser itself is swappable, which is the decision that let ESLint survive TypeScript.
@typescript-eslint/parser produces an ESTree-shaped tree from TypeScript source, so every
existing rule keeps working on a language ESLint was not designed for. A tool with a
hard-wired parser would have needed a rewrite.
Why the plugin ecosystem cannot be reimplemented#
The Rust linters reimplement popular rules. They cannot run your rule, and they cannot run the long tail — the framework plugins, the accessibility rules, the internal package that encodes one team’s conventions.
This is the asymmetry that keeps ESLint at 160.1M weekly downloads against challengers at 19.4M and 14.3M. Speed is a number you can beat. An ecosystem of rules other people wrote and maintain is not, and the reason is structural: to run an ESLint plugin you must embed a JavaScript runtime and call into it per node, which costs most of what the Rust rewrite was for.
Type-aware rules are a different kind of thing#
A rule like “do not await a value that is not a Promise” cannot be answered from the
syntax tree. It requires asking the TypeScript compiler what the value’s type actually is,
which means running the type checker and keeping its program object alive.
That is expensive — it is the reason type-aware linting is opt-in and is usually the dominant cost in a lint run that enables it — and it is why the Rust linters do not offer it. oxlint says so explicitly and scopes itself to what the syntax tree can answer.
It also means benchmarks in this category must state whether type-aware rules were on. A
comparison that runs ESLint with them and oxlint without is not measuring the same job.
This survey’s harness runs neither (see measurement-plan.md), and 1.251 is where the type
checkers themselves belong.
Flat config#
The move from .eslintrc cascading configuration to eslint.config.js — a plain array of
config objects, resolved in order, with explicit imports rather than string-name magic —
made configuration ordinary JavaScript. The migration cost was real: plugins had to be
republished, and a project pinned to the old format is pinned to old plugin versions too.
The architectural gain is that config resolution stopped being a search algorithm over the filesystem. What applies to a file is now something you can read off the array.
The formatting rules are still there#
indent, semi, quotes and their relatives remain in lib/rules in ESLint 10, with
users directed to @stylistic. They are a fossil of the era before Prettier, when the
linter was the only tool that could enforce layout.
Running them alongside a formatter produces two tools rewriting the same lines to different
targets, which is what eslint-config-prettier exists to prevent. The category’s own
consensus has moved past this — Stylelint deleted its stylistic rules outright in v15 —
and ESLint has kept them for compatibility. Do not lint for formatting.
Feature comparison — Linters & Static Analysis#
Everything here is an architectural property, not a benchmark. Numbers are in
performance-benchmarks.md; adoption figures are in S1, read from registries on
2026-08-29.
The property that predicts everything else#
| Tool | Unit of analysis | Can see across files? | Executes your code? |
|---|---|---|---|
| Ruff | one file | mostly no | no |
| Flake8 | one file | no | no |
| Pylint | whole program | yes — inference model | no (resolves imports) |
| Bandit | one file | no | no |
| Semgrep | one file (OSS tier) | paid tiers only | no |
| Vulture | whole program | yes — defined vs used names | no |
| ESLint | one file | only via type-aware rules | no |
| oxlint | one file | no | no |
| Biome | one file | no | no |
| Stylelint | one file | no | no |
Read down the third column and the category sorts itself. Everything that answers “is this used anywhere” or “does this call match its definition” is slow, and everything fast is fast because it never asked.
Where rules come from#
| Tool | Rules compiled in | Plugin ecosystem | You can write a rule in |
|---|---|---|---|
| Ruff | yes | none | — |
| Flake8 | no | large, mature | Python |
| Pylint | no | yes | Python |
| ESLint | no | the largest in any language | JavaScript |
| oxlint | yes | none | — |
| Biome | yes | none | — |
| Semgrep | no | community registry | YAML patterns that look like the code |
| Stylelint | no | yes | JavaScript |
| Bandit | no | yes (plugins exist, rarely used) | Python |
| Vulture | n/a — one rule | n/a | — |
This is the table that decides migrations. A team can leave Flake8 for Ruff if and only if the “Python” cell was never used. Semgrep is the outlier: the only tool where authoring a rule does not require learning an AST API, which changes who on a team is able to write one.
Autofix#
| Autofix | Notes | |
|---|---|---|
| Ruff | yes, safe/unsafe split | “unsafe” means can change behavior, not probably wrong |
| ESLint | yes, per-rule | rules declare fixability |
| Biome | yes, safe/unsafe split | |
| oxlint | yes, limited | |
| Stylelint | yes | |
| Flake8 | no | reports only |
| Pylint | no | reports only |
| Bandit | no | a security finding has no mechanical fix |
| Semgrep | yes, per-rule fix: key | |
| Vulture | no — and should not | deleting on a heuristic is how you delete a fixture |
License, and when it matters#
| License | Tools |
|---|---|
| MIT | Ruff, Flake8, ESLint, oxlint, Stylelint, Vulture |
| Apache-2.0 | Bandit, Biome (repo; npm dual MIT OR Apache-2.0) |
| GPL-2.0-or-later | Pylint |
| LGPL-2.1-or-later | Semgrep |
Running any of these over your code in CI creates no obligation and does not affect your code’s license. The distinction bites in exactly one case — embedding a tool in a product you distribute — which is the position an IDE, a code-review product or a developer-tools vendor is in, and nobody else. 1.253 found licensing was not a differentiator among formatters; here it is, for that one audience.
Scope overlap#
- Ruff
S≈ Bandit. Ruff ports Bandit’s rules. If you run Ruff, enableSbefore adding a second tool. - Ruff
I≈ isort, and belongs to 1.253 — import sorting behaves like formatting. - Ruff
E/W/F/C90≈ Flake8’s default, which is why they are the pair this survey benchmarks against each other. - Ruff covers only part of Pylint. The cheap checks were ported; the inference-based ones were not, and cannot be without giving up the per-file model.
- Biome lints CSS, overlapping Stylelint — but not SCSS, Less or CSS-in-JS.
- ESLint’s stylistic rules overlap every formatter. Turn them off; see 1.253.
Measurement plan (Step 3.5) — 1.250 Linters & Static Analysis#
Written before S2, per ADDING-RESEARCH.md. The point is to decide what will be RUN, and
how far up docs/map/17-the-evidence-ladder.md this subject can go, before writing a word
of analysis — which is what stops S2 from either citing a number nobody checked or
disappearing for a week into a benchmark rig.
Why this survey has a measurement obligation#
Every speed claim in this category is a vendor figure or a repetition of one. Worse, the adjacent survey has already established that the obvious way to check them is wrong:
1.253 measured ruff format against Black at 32.3× on aarch64 and 16.1× on x86_64 —
same container, same pinned corpus, same core count. The ratio depends on the
architecture, by roughly a factor of two, and diverges as the corpus grows. Its own
conclusion was that reporting one number was the error, not either measurement.
That result is a hypothesis for this survey, not an assumption. If the divergence is a property of Rust-versus-interpreted comparisons generally, it should reappear in linting. If it does not, that is more interesting still, and either way a single-platform figure here would be as misleading as it was there.
Two specific claims are outstanding and named:
- “Biome is 15× faster than ESLint.” 1.253 carried this and explicitly declined to test it, because its harness compared formatters only and this is a linting claim. It is this survey’s to settle.
- Astral’s “10-100×”. 1.253 established the formatting half sits at the bottom of that range, 16-32×. The linting half is untested.
The ladder, per level#
Levels are from docs/map/17-the-evidence-ladder.md.
| Level | Applies here? | What it would cover |
|---|---|---|
repeated | Reject as evidence | The vendor figures above. Recorded as claims to CHECK, never as findings. |
cited | Partial | Download and star figures, versions, licenses — already read from primary sources on 2026-08-29 and dated in S1. Legitimate at this level because a registry API IS the primary source. |
measured-local | Yes — the target | Lint wall time, per tool, per corpus size, per architecture. |
measured-browser | No, and stated why | Ruff, Biome and oxlint are compiled binaries. None runs in a reader’s browser, so there is no in-page rung for this subject. The substitute obligation is publishing the method completely enough that a reader reproduces it. |
What will actually be run#
Extend the 1.253 harness rather than build one. It already has the container, the pinned immutable corpus, the fresh-copy-per-tool discipline, caches off, first pass discarded, median of nine, and a droplet script for the x86 cell. Rebuilding it would introduce differences that make the two surveys incomparable — and comparability with 1.253 is itself a result, since it lets the linting and formatting ratios be read against each other.
Cells: two architectures (the aarch64 laptop and the same DigitalOcean
s5-8vcpu-16gb-30gb, 8 vCPU, so architecture stays the only variable) × two corpus sizes
(1 MB and 12 MB) — matching 1.253 exactly.
Comparisons:
ruff checkvs Flake8 vs Pylint (Python)biome lintvsoxlintvs ESLint (JS/TS)
The rule-set problem, which is the hard part of this measurement and must be solved before any number is reported. Formatters are comparable by default: they all do the same job, so timing them is fair. Linters are not. Ruff with three rules enabled and Flake8 with three hundred are not doing comparable work, and a ratio between them measures the configuration, not the tool.
The plan is to time matched rule sets — the rules Ruff’s F and E prefixes share
with Pyflakes and pycodestyle, and for JavaScript a named intersection of rules all three
tools implement — and to report the configuration alongside every number. Where a matched
set cannot be constructed, the cell is reported as not comparable rather than
filled in. A ratio with no workload is not publishable is already the house rule; for
linters the workload includes the rule set.
We do not use Astral’s corpus, and that has to be said out loud#
Ruff’s README captions its benchmark chart “Linting the CPython codebase from scratch” —
read from the repository on 2026-08-29. That is the corpus behind the 10-100× claim.
Ours is different, and stays different. The 1.253 harness pins requests 2.32.5,
jinja2 3.1.6 and click 8.1.8 — 1 MB / 59 files, replicated to 12 MB / 900. Three
reasons not to switch:
- Reproducibility. PyPI never republishes a version under the same name, so the corpus is fetchable again exactly, years from now. “The CPython codebase” is a moving target unless pinned to a tag, and a benchmark whose corpus drifts is not a benchmark.
- Representativeness. CPython’s
Lib/is old, heavily reviewed stdlib code with its own conventions. It is not what a reader’s application looks like, and the reader is who this is for. - Comparability with ourselves. Using 1.253’s exact corpus is what lets the linting ratio be read against the formatting ratio. Change the corpus and that comparison is gone — and that comparison is one of the results.
The obligation this creates: report the difference wherever a number of ours sits near one of theirs. Our figure will not refute Astral’s chart and must not be written as though it does; different corpus, different machine, different question. What ours can say is what the ratio looks like on a mid-sized real dependency tree, which is the codebase most readers actually have.
Also noted from that README, and NOT to be reported as findings: it carries third-party
testimonials of “~1000x faster” and “~150-200x faster than flake8”. Those are repeated
tier — somebody’s machine, no workload named. They go in the claims list as things to
check, exactly as 1.253 handled the 30-100× figure it could not settle.
Scope: extend, do not rebuild#
The harness for this category already exists and is published. This survey adds lint invocations to it and nothing else:
- same container (
python:3.12.11-slim-bookworm, Node 22), same pinned corpus, same fresh-copy-per-tool, caches off, first pass discarded, median of nine; - same two architectures and two corpus sizes, so the cells line up with 1.253’s;
- new:
ruff check, Flake8, Pylint,biome lint,oxlint, ESLint.
Deliberately NOT done: a second harness, a new corpus, a third architecture, or a
per-rule-family breakdown. The rung being climbed is measured-local, the same one 1.253
reached, and the marginal value of going further is low next to the cost. If the matched
rule-set problem below cannot be solved for a pair, that pair is reported as not
comparable rather than measured harder.
Not measured, and why#
- Pylint’s inference checks have no equivalent in the fast tools, so there is nothing to form a ratio against. Timing Pylint alone is a fact about Pylint, and will be reported as one rather than as a comparison.
- Semgrep, Bandit, Vulture, Stylelint — each answers a question no other tool asks. There is no matched rule set, so no ratio.
- Type-aware ESLint rules, which require the TypeScript compiler in the loop. Timing
them would be timing
tsc, which belongs to 1.251. - Finding quality. Whether a tool’s findings are correct is not a wall-clock question and is out of scope for this rung. It is the more important question, and saying so is better than pretending a benchmark answered it.
Floor model#
None. The two-part test in the floor-model ladder is that Pyodide can run the category’s top libraries and that there is a counterintuitive measurable. This category fails the first half outright — the tools that matter are compiled binaries. The harness is the deliverable instead, published so a reader can run it on their own code.
Measured: how fast, on what, with which rules#
Full method, raw numbers and limits: harness/1-253-code-formatting/RESULTS-LINT.md.
The plan these were run against is measurement-plan.md, written before any of it.
The reference is a clean machine. Every figure below is from a DigitalOcean
s5-8vcpu-16gb-30gb (8 vCPU AMD EPYC 9555P) whose load average sat at exactly 1.00 for the
whole run. An earlier version of this file reported laptop numbers; those are now a
comparison column, because this harness times tools sequentially and a machine doing other
work biases the ratio between two tools’ windows. The rule that survives:
A ratio is only as good as the spread on both sides of it.
Moving to a dedicated box cut run-to-run spread by roughly an order of magnitude — ruff check from 36% to 4%, ESLint from 26% to 4%, Flake8 from 14% to 2%.
Python: ruff check against Flake8#
Matched rule sets. Ruff runs --select E,W,F,C90 — pycodestyle, Pyflakes and McCabe,
which is exactly Flake8’s default. Both tools are doing the same work, which is the only
condition under which a ratio between linters means anything.
| corpus | ruff check | Flake8 | ratio |
|---|---|---|---|
| 1 MB / 59 files | 0.0143 s | 0.4009 s | 28.0× |
| 12 MB / 900 files | 0.0673 s | 2.5499 s | 37.9× |
Spreads of 4% and 2% on the two sides, which is what licenses the ratio. The contended laptop measured 17.4× and 30.4× for the same comparison — lower at both sizes, with 36% spread on its ruff cell. Those two pairs are not comparable and the difference must not be read as an architecture finding until a clean ARM cell exists.
The ratio grows with the codebase, and that reproduces what 1.253 found for the
formatting half on the same architecture — ruff format against Black went 20.5× → 32.3×
over the same two corpus sizes. Two independent comparisons, same shape.
The ratio holds up under noise even though the individual timings do not. Ruff’s runs carry 36% spread; but both tools are timed on the same machine in the same run, so the test is whether the ratio moves when computed from the fastest, median and slowest runs — 30.8×, 30.4×, 25.9×. It does not.
Against Astral’s 10-100×: 17-30× sits inside it, at the bottom, which is where 1.253
also landed for formatting. Note their chart is measured on the CPython codebase and this
is not; different corpus, different question, and nothing here contradicts it.
Pylint: the clearest result, and not a ratio#
Pylint is timed alone. Nothing else in the category does whole-program inference, so there is no matched rule set and no comparison to draw.
| corpus | Pylint (x86) | spread | Pylint (ARM) | spread |
|---|---|---|---|---|
| 1 MB / 59 files | 14.81 s | 2% | 14.46 s | 2.9% |
| 12 MB / 900 files | 348.38 s | 1% | 349.95 s | 2.1% |
23.5× on x86 and 24.2× on ARM for a 12× corpus — Pylint’s cost is superlinear, and the curve reproduces across architectures.
That agreement is the finding, not the individual number. A shape that holds on two different
machines at 1-2% spread is a property of whole-program inference rather than of anyone’s
hardware. ruff check measured 4.7× on both machines, identically — sublinear, because
fixed startup amortises and per-file work parallelises.
At 2.1% spread this is the most reliable measurement in the survey: a 350-second run
averages out every source of noise that ruins the fast tools. It is also the measured
consequence of the architecture described in pylint.md — inference is a graph problem
across the whole program, and the cost grows faster than the file count.
The practical reading is not “Pylint is slow”, which everyone already says. It is that Pylint’s cost grows faster than your codebase does, so a tool that is fine on a small service becomes a CI problem on a monolith, and it gets worse rather than proportional. Narrow the path selection rather than absorbing the wall time.
It is also the clearest evidence for the survey’s central architectural claim: Ruff reimplemented Pylint’s cheap per-file checks and not its expensive whole-program ones, and this is the cost that explains why.
JavaScript: “Biome is 15× faster than ESLint” — settled, and unsupported#
1.253 carried this claim and could not test it, because its harness compares formatters and this is a linting claim. This survey took it up, and on a clean machine with matched rule sets it does not hold.
| comparison | 1 MB | 12 MB | spreads @ 12 MB |
|---|---|---|---|
| Biome lint vs ESLint | 4.0× | 3.5× | 3% / 4% |
| oxlint vs ESLint | 5.8× | 15.8× | 26% / 4% |
Biome measures 3.5×, not 15×. Two qualifications, both of which cut toward caution rather than toward the claim:
oxlint gets close to the figure, and Biome is the tool the claim names. If a real measurement sits behind the circulating number it may belong to a different tool. But oxlint’s cell carries 26% spread even on a quiet box, because at 55 ms it is below the measurement floor — suggestive, not evidence.
Our rule set is not theirs. This is an intersection config all three tools implement,
with no type-aware rules. A comparison running ESLint with typescript-eslint against Biome
without it would produce a much larger number while measuring a different job.
An earlier version of this file reported this comparison as unsettled for a different reason: the first attempt was invalid because ESLint exited 2 and never linted anything, and the tell was that it appeared to get faster on a 12× corpus. That is fixed; the number above is from a working run.
What these numbers cannot carry#
One architecture. Every cell is aarch64. 1.253 originally reported the ruff-vs-black ratio as roughly twice as large on ARM as on x86, and withdrew that finding on 2026-08-29 — its ARM cells came from a contended machine and this harness times each tool in its own window. So the architecture question is open for both surveys, not answered by either. A clean ARM cell is outstanding work here for the same reason it is there.
A shared machine, and a floor. Run-to-run spread tracks runtime almost exactly: Pylint 2.1%, Flake8 14%, ESLint 27%, ruff 36%, oxlint 77%. Below roughly 100 ms, wall-clock on a machine doing other things is measuring the scheduler. 1.253 never met this because its slowest tools took seconds; linters are an order of magnitude faster, which puts three of these under the floor. Ratios between tools timed in the same run survive it. Absolute times below ~100 ms on this hardware do not, and are reported with their spread rather than alone.
Not measured at all: finding quality — whether a tool’s complaints are correct — which is the more important question and is not a wall-clock one. Also Bandit, Semgrep, Vulture and Stylelint, none of which has a matched counterpart to form a ratio with, and type-aware ESLint rules, which would be a measurement of the TypeScript compiler and belong to 1.251.
Pylint — architecture#
Inference is the whole design#
Pylint parses through astroid, its own AST layer, and astroid does something the other
Python tools here do not attempt: it builds an inference model. Asked what a name
refers to, it tries to answer — following assignments, resolving imports, walking class
hierarchies, tracking what a function returns.
That is why Pylint can say a method is called with the wrong number of arguments, or that
an attribute is never assigned on any path, or that a class inherits an abstract method it
never implements. Those questions require knowing what self.thing actually is, and
that is not a question the syntax tree answers.
It is also why Pylint is slow, and the slowness is not an implementation defect a Rust rewrite would fix. Inference is a graph problem over the whole program, and the cost is in the problem, not the language. Ruff has reimplemented the cheap Pylint checks — the ones answerable per-file — and not the expensive ones, which is the clearest available evidence that the split is architectural rather than a matter of effort.
Inference is also why it is wrong sometimes#
A model that resolves names has to give up somewhere, and Python gives it many
opportunities: attributes set by setattr, classes built by metaclass, objects returned
from a factory typed only at runtime, anything reached through a plugin registry. When
inference cannot resolve a name it either stays silent or guesses, and both produce
findings a human has to overrule.
This explains Pylint’s reputation for false positives. It is not that the rules are careless; it is that the tool is attempting a harder question than the tools it gets compared to, and hard questions have wrong answers.
The rule categories, and why teams turn half of them off#
Pylint ships five kinds of message — fatal, error, warning, refactor, convention — and the last two are opinions about design rather than reports of defects. Too many arguments, too many branches, too many instance attributes, inconsistent return statements.
Convention messages overlap heavily with what a formatter has already settled, and refactor messages are the ones teams most often disable wholesale. That is a reasonable response, and it points at the real adoption failure in this tool: Pylint out of the box is not a configuration anyone should run. The teams that got value from it configured it; the teams that abandoned it usually did not.
What running it costs, structurally#
Pylint holds more of the program in memory than the per-file tools and cannot parallelise
as cleanly, because the inference model is shared state. --jobs exists and helps, at the
cost of some cross-module checks that need the single shared model to be correct.
The practical shape that follows: Pylint is not a save-hook tool. It belongs on a slower CI job, or on a narrowed path selection, running alongside a fast per-file linter rather than instead of one.
GPL-2.0-or-later#
Restated here because it is an architecture-adjacent fact rather than a footnote: Pylint is the only major Python linter under a copyleft license. Running it over your code in CI creates no obligation and does not affect your code’s license. Linking or vendoring it into a product you distribute does. See S4 for what that means for a tools vendor.
S2 Recommendation — what the architecture and the measurements say together#
S2’s claim is that architecture predicts the trade-off, and the measurements support it in the one place they are strong enough to.
The architecture claim, and the number that carries it#
What a linter can see is a function of how much of the program it holds at once, and its cost follows from the same fact. Everything fast in this category reads one file at a time.
Pylint at 349.95 s on 12 MB against 14.46 s on 1 MB — 24.2× for a 12× corpus — is that claim measured. Inference across a whole program is a graph problem whose cost grows faster than the file count, and this is what that looks like on real code. At 2.1% spread it is also the most trustworthy number in the survey.
That single measurement explains the shape of the whole category. It is why Ruff reimplemented Pylint’s cheap per-file checks and not its expensive whole-program ones; the expensive ones are not slow because nobody optimized them, they are slow because of what they are asking.
Ruff against Flake8: real, and size-dependent#
17.4× at 1 MB, 30.4× at 12 MB, matched rule sets, stable across estimators. Two things follow.
The speed claim is real and sits at the bottom of Astral’s advertised 10-100× — the
same place 1.253 found the formatting half.
The ratio is a property of the workload, not a constant. It grew with corpus size here
exactly as ruff format against Black did in 1.253 on the same architecture. Any figure
quoted without a workload attached — including this one — is incomplete.
What S2 could not settle#
The JavaScript comparison. The first measurement was invalid: ESLint never linted
anything, because ESLint 10 resolves flat-config files patterns relative to the config’s
location and exit 2 had been accepted as a valid result. The corrected 3-repetition check
suggests roughly 4× for oxlint and 3.6× for Biome, which is far from the circulating 15×
but is below this harness’s own repetition threshold.
So: “Biome is 15× faster than ESLint” is not supported by anything measured here, and is
not shown to be false either. Those are different statements and the survey should not
blur them. See performance-benchmarks.md.
What follows for a reader#
Speed is not the axis to choose on, and the measurements are what make that a finding rather than a slogan. The Ruff-versus-Flake8 gap is real and, for most codebases, worth less than it sounds: a lint step going from 2.6 s to 0.09 s has improved something nobody was waiting on. The Pylint number is the one with operational consequences, and it points at scoping rather than tool choice.
The decisions that actually bind are the architectural ones from feature-comparison.md:
- Can your rules be somebody else’s rules? If not, the compiled tools are unavailable at any speed, because a rule you wrote cannot be loaded into a Rust binary.
- How much of the program does your question need? “Is this function called anywhere” is not a question a fast tool declined to answer. It is one it cannot see far enough to ask.
Both are settled by reading the tool’s design, not by timing it.
Method notes worth carrying into any future benchmark here#
- Matched rule sets or no ratio. Two linters running different rules are not doing comparable work, whatever the stopwatch says.
- A linter’s non-zero exit is normal. It means “I found something”. Pylint returns a bitmask; ESLint’s 2 is fatal and must not be accepted. Getting this wrong produced nine timed error messages that looked like data.
- Watch for a ratio that does not move with corpus size. ESLint appearing to get faster on a 12× corpus was the symptom that exposed the broken cell.
- There is a floor. Below ~100 ms on a shared machine, wall-clock measures the scheduler. Report the spread, or use a dedicated box.
Ruff — architecture#
What it parses#
Ruff has its own Python parser, written in Rust, producing its own AST. It does not use
CPython’s ast module and does not import your code. That is the first architectural
decision and most of the rest follows from it.
Not importing means Ruff never executes anything — no __init__.py side effects, no
imports resolved by running them. Pylint’s astroid also avoids executing user code but
does resolve imports to build its inference model, which is a different bargain.
The parser is also the reason Ruff can lint code it cannot run: syntax from a newer Python
than the interpreter Ruff was installed under, or a file with an error in it, where a
tool depending on ast.parse gets an exception and stops.
The unit of work is a file#
Each file is parsed and checked independently, which makes the work embarrassingly parallel — Ruff uses all cores by default — and puts a hard ceiling on what it can know. Nothing that requires seeing two files at once is available: whether a public function is called anywhere, whether a method’s signature matches its call sites, whether a subclass implements what its base declares.
Ruff has been adding cross-file capability in narrow places, but the per-file model is the default and the reason for the speed. This is worth stating plainly against the marketing: Ruff is not a fast Pylint. It is a fast Flake8, and the checks it does not do are mostly the ones that need more than one file.
Where rules come from, and cannot come from#
Rules are Rust code compiled into the binary. The rule set is organized by the tool each
family came from — E/W from pycodestyle, F from Pyflakes, I from isort, B from
bugbear, S from Bandit, UP from pyupgrade — which makes migration close to mechanical
for a Flake8 configuration.
There is no plugin interface, and this is structural rather than a missing feature. A Python plugin API would mean loading Python into a Rust process and calling into it per node, which would cost most of the speed the rewrite bought. The consequence for a team is absolute: a house rule that nobody upstreamed has nowhere to live. That is the single most common legitimate reason not to migrate off Flake8, and no amount of speed answers it.
Autofix, and why “unsafe” is the wrong word to skim past#
Rules may carry a fix, classified safe or unsafe. Unsafe here does not mean “probably
wrong” — it means the fix can change runtime behavior. Removing an unused import is
safe unless the import has a side effect; Ruff cannot know whether it does, so that fix
is unsafe. Teams that enable --unsafe-fixes globally to clear a backlog are accepting
exactly that class of risk, on a codebase where the tool has told them it cannot see far
enough to check.
The pre-1.0 problem is a CI problem#
At 0.16.5, four years in, minor versions add rules. Added rules mean new findings on code that passed yesterday, which means a build that goes red without anyone changing the code. Pin the exact version in CI and in pre-commit, and treat a Ruff bump as a change to review rather than a dependency update to wave through.
Biome and oxlint — the Rust architecture#
Treated together because their architectural bet is the same and their scoping decisions are what differ. Both are Rust, both parse to their own AST, both compile rules into the binary, and both therefore share one hard limit: you cannot write a rule for them in the language you already use.
What the Rust rewrite actually buys#
Three things, and speed is only the first.
No interpreter startup, and real parallelism. ESLint pays Node’s startup on every invocation and JavaScript’s threading model for the rest.
A parser built for tooling rather than execution. Both use error-tolerant parsers that produce a usable tree from broken source — a parser that must stop at the first error is no use to an editor that runs the linter on every keystroke.
One binary, no dependency tree. Biome’s pitch in particular is that formatter and
linter cannot disagree because they are the same program, which removes the
eslint-config-prettier class of problem entirely rather than configuring around it.
Where they diverge: scope#
Biome is a replacement. Formatter and linter in one tool, rules organized by intent — correctness, suspicious, style, complexity, security, accessibility, performance — rather than by which historical tool they came from. The proposition is fewer tools.
oxlint is a stage. It implements the rules that are cheap from the syntax tree, skips the type-aware ones by design, and expects to run in front of ESLint rather than instead of it. The proposition is a faster first pass.
That difference explains the adoption numbers better than quality does. oxlint’s 19.4M weekly downloads against Biome’s 14.3M is not evidence that oxlint is the better linter; it is consistent with oxlint being additive — installing it costs you nothing you already have — while Biome asks a project to migrate off two tools it already trusts.
The shared ceiling#
Neither can run an ESLint plugin. Both reimplement popular rules and neither reimplements
the tail, and the tail is where a codebase’s own conventions live. For a team whose lint
config is mostly eslint:recommended plus a framework preset, that ceiling may never be
reached. For a team with an internal rules package, it is reached on day one.
Both also inherit Biome’s language-coverage boundary, recorded in 1.253: HTML, Vue, Svelte and Astro support arrived recently and remains experimental and opt-in; YAML and Markdown are unsupported. A linter that cannot read a file cannot lint it, and a repo with meaningful Vue templates is a repo where the consolidation argument is weaker than it sounds.
Version velocity is a real cost#
oxlint at 1.80.0 and Biome at 2.5.11 both ship constantly, and both add rules as they go. Added rules mean new findings on unchanged code — the same CI hazard Ruff’s pre-1.0 status creates in Python, and the same mitigation: pin the exact version, and treat a bump as a change to review.
S3: Need-Driven
S3 Approach — who needs a linter, and why#
Six people with real jobs. Each is a position someone is actually in, not a segment — and the tool that wins changes between them, which is the point of writing them down rather than publishing one ranking.
The organizing question, established in S1 and confirmed by S2’s architecture pass, is not which linter is fastest. It is whether your rules can be somebody else’s rules. A team running a standard rule set has a different category available to it than a team whose value is in rules nobody upstream would accept, and speed does not decide between them.
A second question separates the rest: how much of the program does the answer need? A linter that reads one file at a time can be fast forever and will never tell you a function is dead.
The personas below are ordered from the most common position to the most specific.
S3 Recommendation — by position#
No single ranking, because the tool that wins changes with the question being asked.
| If you are… | Use | The deciding reason |
|---|---|---|
| Starting a Python project | ruff check, S enabled | One tool where there were four |
| Migrating off Flake8 | Ruff — unless a plugin blocks it | Compiled rules cannot run your plugin |
| Wanting deeper Python checks | Pylint, on a slower job | Only tool doing whole-program inference |
| Slow TypeScript lint | oxlint in front of ESLint | Keeps type-aware rules and your plugins |
| Wanting fewer JS tools | Biome | Formatter and linter that cannot disagree |
| Producing a security report | Ruff S, then Bandit | Baseline and confidence scoring |
| Enforcing your own conventions | Semgrep | The only one where a rule is not a plugin |
| Shipping a product that lints | Ruff / ESLint, MIT | Pylint is GPL; you are distributing |
The two questions that sort the category#
Can your rules be somebody else’s rules? If yes, the fast compiled tools are available and you should use them. If no, you need an extension point, and that means Flake8, ESLint or Semgrep — with Semgrep the only one where authoring a rule is not a tooling project.
How much of the program does your answer need? Everything fast reads one file at a time. “Is this function called anywhere” and “does this call match its definition” are not questions a fast tool declined to answer; they are questions it cannot see far enough to ask. That is what Pylint and Vulture are for, and why they are slow.
What the personas agree on#
- Never lint for formatting. Every persona keeps a formatter (1.253) and turns stylistic lint rules off. Stylelint deleted its own in v15; copy that.
- Pin the version. Ruff is pre-1.0, oxlint and Biome ship weekly, and all three add rules. Added rules turn a green build red with no code change.
- Ruff
Sbefore Bandit, always — it is free if Ruff is installed. - A speed claim without a workload and a rule set is not a claim. Two tools running different rule sets are not doing comparable work, whatever the ratio says.
Persona: a team that keeps making the same mistake#
Who: a platform or backend team that has learned something the hard way. Every service
must call the auth wrapper, not the raw client. Nobody may import from internal.legacy.
Every outbound request needs a timeout. The rule is written in a wiki page and violated
every quarter.
What they need: the rule enforced by a machine, without a tooling project.
This is the persona the whole category is organized around#
S1’s finding was that linters split by posture rather than speed, and the question that sorts them is whether your rules can be somebody else’s rules. For this persona the answer is no, by definition. The valuable rule is specific to one codebase and no upstream project will ever ship it.
That eliminates most of the category in one move. Ruff, Biome and oxlint compile their rules into a Rust binary and have no plugin interface — not as an oversight but because a plugin API would cost the speed the rewrite bought.
Recommendation: Semgrep#
It is the only tool here where writing a rule does not mean learning an AST API. A Semgrep
pattern looks like the code it matches, with metavariables and ellipses for the parts
that vary. A rule for “requests.get called without a timeout” is close to that call with
holes in it, written in YAML.
The consequence is about who can author a rule, and it is the real argument for the tool: it moves custom static analysis from a tooling specialist to any developer on the team. Writing the equivalent ESLint or Flake8 plugin means an AST visitor, a package, and a release — which is why the wiki page exists instead.
It is also multi-language, which matters if the same convention has to hold in Python and TypeScript.
What it is not#
Not a replacement for Ruff or ESLint. Semgrep will not tell you a variable is unused. Keep the general linter and add Semgrep for the rules that are yours.
Not a data-flow analyzer at the free tier. Patterns match within a file. If the rule is “no untrusted input reaches this sink anywhere”, that is a paid capability.
The alternative, if Semgrep is not an option#
A Flake8 or ESLint plugin, which is the older answer and still works — and is precisely the reason those two tools have survived being slow. If the team already maintains one, that is also the reason a migration to Ruff or Biome is blocked, and the two facts are the same fact.
Persona: a large TypeScript application where lint time hurts#
Who: a front-end team on a monorepo. ESLint with typescript-eslint, a framework
plugin, an accessibility plugin, and an internal rules package. The lint step is the
slowest thing in CI and the editor lags on save.
What they need: the wait to stop, without losing rules they depend on.
The answer starts with a measurement they should take themselves#
This survey measured oxlint at 1.2× and biome lint at 1.4× faster than ESLint
on a 1 MB JavaScript corpus with a matched rule set — nowhere near the “15× faster than
ESLint” figure that circulates, which 1.253 carried and explicitly declined to test.
Two caveats, both of which cut toward taking your own measurement:
- Our JS corpus is small (198 KB at ×1) and, at that size, process startup is a large
share of every tool’s wall time. See
performance-benchmarks.mdfor how the ratio moves with corpus size, which is the number that generalises. - We measured no type-aware rules. In a real TypeScript configuration those are usually the dominant cost, and they exist only in ESLint — because answering them means running the TypeScript compiler, which is 1.251’s subject and not something a Rust linter is choosing not to do.
If type-aware rules are on, they are probably your bottleneck, and no Rust linter removes them. Measure with them off and on before concluding the linter is the problem.
What to do#
Add oxlint in front of ESLint, do not replace it. It runs the cheap correctness rules fast and skips the type-aware ones by design. The fast pass catches the common mistakes on save; ESLint stays for the rules that need types and for your plugins.
The cost this hides: two tools means two rule configurations that must not disagree. A rule enabled in one and not the other produces findings that appear and disappear depending on which tool ran, which is worse than a slow lint step. Budget the config maintenance, not just the install.
Consider Biome only if you can drop the plugins. Its case is fewer tools, not more speed — one binary where the formatter and linter cannot disagree. On a codebase with an internal ESLint rules package that case does not close, because Biome cannot run it.
What will not help#
Turning off rules to go faster. That is a decision about code quality dressed as a decision about performance, and it should be made on the merits with the timing set aside.
Persona: a codebase that has run Flake8 for eight years#
Who: a maintainer of a mature Python service. setup.cfg has a [flake8] section
with an ignore list nobody remembers writing, four plugins, and a per-file-ignores block.
CI is slow enough to notice.
What they need: to know whether migrating is worth it, and whether it is safe.
First: check the one thing that blocks it#
Does any of those four plugins have no Ruff equivalent? Ruff’s rules are compiled into the binary and there is no plugin interface, so a Flake8 plugin — especially an internal one — is a hard blocker rather than a migration cost. This is the single most common legitimate reason to stay, and it should be checked before anything else.
If every plugin maps to a Ruff prefix, migrate. If one does not, the choice is to keep Flake8, drop the rule, or reimplement it in Semgrep.
Second: do not migrate out of fear#
Flake8 has not released since 2025-06-20 and that reads like abandonment. It is not. It carries 23 open issues against ten million weekly downloads — a tracker somebody empties, on a tool whose problem stopped changing. Compare Pylint’s 1,081 and Ruff’s 2,154: those are healthy projects with moving scope, not better-maintained ones.
The dead tool in the adjacent survey, Blue in 1.253, had no release since 2022 and no commits, and could not be installed beside a current Black at all. Flake8 does not resemble that. Low churn is what finished software looks like.
The migration, mechanically#
Ruff’s prefixes are named after the tools they came from — E/W pycodestyle, F
Pyflakes, C90 McCabe, B bugbear, I isort — so an existing configuration maps almost
directly. Migrate the ignore list as-is first and clean it up later; translating it and
tidying it in the same commit makes the diff unreviewable.
Expect new findings even with a matched rule set. Ruff’s implementations are not bug-compatible with the originals, and it is not unusual to discover the old configuration was suppressing something real.
What they actually gain#
17.4× on a matched rule set at 1 MB in this survey’s harness. Whether that matters is a question about their CI, not about the tools: a lint step that takes four seconds and drops to a quarter of one has improved something nobody was waiting on. The consolidation — four tools to one, four config files to one block — is usually the larger win and is the one that is hard to measure.
Persona: starting a new Python project#
Who: a developer or a small team opening an empty repository this month. No legacy configuration, no house rules yet, nobody attached to a tool.
What they need: to stop thinking about this. Lint on save, lint in CI, one config block, no argument about it in review.
Recommendation: ruff check, and nothing else at first#
One binary, one config block in pyproject.toml, no plugin tree to assemble. It covers
the Flake8 default rules, the popular Flake8 plugins, isort’s import ordering and a port of
Bandit’s security rules — the whole conventional Python lint stack, which used to be four
tools and four config files.
Measured here at 17.4× Flake8 on a 1 MB corpus with matched rule sets (both running pycodestyle + Pyflakes + McCabe), so the speed claim is real at this size even though it sits at the bottom of Astral’s advertised 10-100×.
Enable S on day one. It is the Bandit port, it is free if Ruff is already installed,
and adding security linting later means triaging a backlog instead of never accumulating
one.
What they should not do#
Do not add Pylint yet. It is 14.46 seconds on 59 files here against Ruff’s 0.018 — and that comparison is unfair in Pylint’s favor to state as a ratio, because Pylint is doing whole-program inference nothing else attempts. The point for this persona is simpler: it is not a save-hook tool, and a new project has no accumulated design debt for the refactor messages to find. Add it when there is a codebase worth its opinions.
Do not lint for formatting. Run a formatter (1.253) and leave layout to it entirely.
Pin the exact Ruff version in CI and pre-commit. It is pre-1.0 at 0.16.5 after four years, minor releases add rules, and added rules turn a green build red without anyone touching the code.
When this persona’s answer changes#
The moment someone says “we should enforce that our database calls all go through
db.py”. That rule cannot exist in Ruff — its rules are compiled into a Rust binary —
and the answer becomes Semgrep alongside it, not a different linter.
Persona: someone who has to produce a security report#
Who: an engineer at a company in a regulated market, or one answering a customer security questionnaire. Somebody outside the team will read the output.
What they need: findings, in a format that can be attached to something, with a defensible story about coverage.
Start with Ruff’s S rules#
They are a port of Bandit’s, and if Ruff is already installed they cost nothing. For a team that has never run security linting, this is the first move and it is free.
Add Bandit when the output has to be an artifact#
What Bandit still has over the port: the complete rule set rather than a subset, a severity and confidence pair on every finding, output formats that security tooling ingests, and — the one that matters most in practice — a baseline mechanism. A baseline lets an existing codebase adopt security linting by accepting today’s findings and failing only on new ones. Without it, adoption on a mature codebase means a thousand-line triage before the first useful signal.
Confidence is the field to read first. Bandit cannot tell whether the string that looks like a password is one, and says so per finding rather than making you guess.
Understand what none of this proves#
Bandit and open-source Semgrep are pattern matchers, not data-flow analyzers. They see
that subprocess was called with a variable. They do not trace whether that variable came
from an HTTP request. Cross-function taint tracking — source to sink across the codebase —
is what commercial SAST sells and what sits in Semgrep’s paid tiers.
So: a clean Bandit run is not a security assessment, and should never be presented as one. It says the known-dangerous shapes are absent. That is worth having and it is not the same claim.
If the requirement is “prove untrusted input cannot reach this sink”, that is a purchase decision, and this survey’s scope ends at the boundary. Say which one you are being asked for before choosing a tool.
The license note that applies here specifically#
Semgrep is LGPL-2.1-or-later and open-core. Running the CLI in your pipeline is unencumbered. If a vendor is proposing to embed it in a product they sell you, that is a different conversation — see S4.
Persona: building a product that analyzes other people’s code#
Who: an IDE plugin, a code-review product, a CI service, an AI coding tool that wants to run static analysis over a customer’s repository and show the results.
What they need: an analyzer they can ship.
This is the one persona for whom license is the first question, not the last#
1.253 found licensing did not differentiate the formatters — MIT nearly across the board — and concluded it was not worth deciding on. That conclusion does not carry to linters.
| License | Tools |
|---|---|
| MIT | Ruff, Flake8, ESLint, oxlint, Stylelint, Vulture |
| Apache-2.0 | Bandit, Biome |
| GPL-2.0-or-later | Pylint |
| LGPL-2.1-or-later | Semgrep |
For every other persona in this survey the distinction is inert: running a GPL program over your source in CI creates no obligation, and no license attaches to its output. For this persona it is decisive, because distribution is the trigger and shipping a product is distribution.
Vendoring or linking Pylint into a distributed product puts the product inside GPL-2.0’s obligations. LGPL-2.1 is lighter and not absent. This is not legal advice and the specifics depend on how the tool is invoked — a subprocess boundary is not the same as linking — but it is the question to take to counsel before the benchmark, not after the architecture is built around it.
The architectural consequence, beyond licensing#
Most of these tools are command-line programs, not libraries. Shipping one means shipping a binary and parsing its output, which sounds worse than it is: it gives a process boundary, a crash you can survive, and a clean upgrade story.
The tools designed to be embedded are a shorter list, and the parsing libraries in 1.252
Python Code Parsing & AST Libraries are the alternative worth weighing — libcst in
particular, for a product that needs to rewrite code rather than only report on it.
That is the substrate question, and a vendor is often better served building on it than
wrapping a linter that was designed to be a CLI.
What to actually do#
Default to Ruff and ESLint — MIT, fast, and the ones a customer’s repository most likely already agrees with, which matters because findings that disagree with the project’s own configuration are noise.
Read the customer’s config rather than imposing yours. Both tools’ configurations are readable files. A product that lints by its own standards produces a wall of findings the team already decided against.
Treat Pylint and Semgrep as integrations, not dependencies — invoked if the customer has them, never bundled.
S4: Strategic
S4 Approach — will these still be here?#
S4 asks the question a survey is uniquely able to answer badly: which of these tools is still a safe dependency in three years?
The trap is treating funding and company health as the answer. A survey compares TOOLS, not the companies behind them. Funding tells you whether something will survive; it never tells you whether it is good, and a well-funded tool can still be the wrong choice.
So the questions here are narrower and checkable:
- Is anyone answering? Not “is it releasing” — release cadence measures how much a project’s scope is still moving, which is not the same as maintenance. The signal is whether the issue tracker is being kept.
- What happens to your codebase if it stops? A linter that dies leaves a config file and a rule set. The migration cost is a function of how portable those are.
- What is the governance? A foundation, a company, or one person are three different risk profiles, and the difference shows up years later.
- Where is the category going? Consolidation is the visible trend; the question is whether it completes.
Every figure cited is from S1, read from primary sources on 2026-08-29.
S4 Recommendation — long-term view#
Safe to depend on for years#
ESLint. OpenJS Foundation governance, 160.1M weekly downloads, 137 open issues on a project of that scale. Its moat — plugins that require a JavaScript runtime to execute — is structural rather than a lead that can be closed by optimization. Its plausible future is a demotion in position, running as the deeper second stage behind a fast Rust pass, which threatens nobody depending on it.
Ruff, with the version pinned. The dependency risk is lower than “0.16.5 after four years” suggests: MIT, large contributor base, 66.7M weekly downloads, and it is now infrastructure other Astral tools rely on. The operational risk is higher than teams assume, and it is entirely the version contract.
Bandit and Stylelint. Community-governed, current, narrow scope. Stylelint in particular has demonstrated it understands its own boundary by deleting its stylistic rules outright.
Safe to keep, do not start#
Flake8. Fourteen months without a release and 23 open issues is finished software, not dying software. Stay if a plugin has no Ruff equivalent — that is the right reason. Start new work on Ruff.
Pylint. Not at risk: shipped 4.0.8 the day this was read. But it is a complement to a fast linter rather than a primary one, and if you are shipping a product that embeds it, the GPL is a question for counsel before it is a question for engineering.
Adopt with an exit in mind#
Biome. Already survived the failure of the company behind Rome, which is the reassuring version of that story. Its risk is position, not survival — at a tenth of ESLint’s downloads and now behind oxlint’s. Its configuration does not map back onto ESLint’s the way Ruff’s maps onto Flake8’s, so leaving costs more than arriving did.
oxlint. Low commitment by construction: it runs alongside ESLint, so removing it is deleting a CI step. That additivity is also the most likely explanation for its download lead over Biome, and it is a genuine strategic advantage rather than an accident.
Semgrep. Depend on the pattern engine, not on the free tier’s boundary. Where open-core draws its line is a business decision that gets revisited.
Use occasionally, do not build on#
Vulture. One maintainer, quietest project here, and that is fine for what it is: a tool you run during a cleanup, whose output is a list of candidates for a human. Not a CI dependency, so the bus factor has low consequence.
The forecast, in one line each#
- Python’s consolidation is done; Ruff’s remaining growth is into checks it can only reach by giving up the per-file model that makes it fast.
- JavaScript will stay two-tier — fast Rust pass plus ESLint — rather than resolving.
- The formatter/linter split is settled, and the projects are enacting it themselves.
- The boundary worth watching is type-aware analysis, which is 1.251’s subject, not a linter release.
Risk assessment#
Ordered by how likely a team is to actually hit it.
1. A version bump turns CI red with no code change — near certain#
Who: anyone floating Ruff, oxlint or Biome.
All three add rules in minor releases. Ruff is pre-1.0 at 0.16.5 four years in, so a minor carries no compatibility promise at all; oxlint at 1.80.0 and Biome at 2.5.11 ship constantly. New rules find new things in unchanged code, and the build goes red on a morning when nobody touched the repo.
Mitigation: pin the exact version in CI and pre-commit — they drift apart otherwise — and treat a bump as a reviewed change. This is the single most common operational failure in this category and it is entirely preventable.
2. Migrating on a speed number and hitting the plugin wall — common#
Who: teams leaving Flake8 or ESLint.
Ruff, Biome and oxlint compile their rules in and have no plugin interface. A team discovers mid-migration that the internal rules package has nowhere to go, and the choice becomes: keep the old tool, drop the rule, or reimplement it.
Mitigation: audit plugins before benchmarking. It is the question that decides the migration, and speed is the one that does not.
3. Reading a quiet project as a dead one — common, and expensive both ways#
Flake8 has not released in fourteen months and carries 23 open issues. That is finished software with an attentive maintainer, not abandonment. A team that migrates in a panic spends weeks it did not need to.
The opposite error costs more. Blue, in 1.253, had no release since 2022 and no commits
and an unsatisfiable pin on black==22.1.0. Those are different pictures.
Mitigation: judge on the tracker and recent commits, not release cadence. Ask “is anyone answering”, not “is anything shipping”.
4. Believing a benchmark that did not state its rule set — common, and this survey’s own hazard#
Two linters running different rule sets are not doing comparable work. Every ratio in this category is a function of configuration, and most published figures do not say what theirs was.
This survey measured oxlint at 1.2× and biome lint at 1.4× faster than ESLint on a 1 MB
corpus with a matched rule set — against a circulating claim of 15×. That gap is mostly
about what was configured and how big the corpus was, not about anyone lying.
Mitigation: treat any ratio without a stated workload and rule set as unusable, this
survey’s included where it does not state both. See performance-benchmarks.md.
5. A single-architecture benchmark generalised — established by the adjacent survey#
1.253 found the ruff-vs-black ratio was 32.3× on aarch64 and 16.1× on x86_64 — same container, same corpus, same core count — and diverged as the corpus grew. Its conclusion was that reporting one number was the error.
Mitigation: this survey measures the same two architectures. Any figure here that comes from one of them says so.
6. Treating a clean security lint as a security assessment — rarer, worse#
Bandit and open-source Semgrep match patterns within a file. They do not trace untrusted input to a dangerous sink; that is data-flow analysis, and it is a paid tier or a commercial SAST product.
Mitigation: state which claim you are making. “The known-dangerous shapes are absent” is worth having and is not “this code is secure.”
7. Depending on an open-core capability that moves — rare, structural#
Semgrep is open-core: cross-file data-flow already sits behind the paid tier, and where the line falls is a business decision that can be revisited.
Mitigation: build on the pattern-matching engine, which is the part with a community around it. Do not build a process that assumes today’s free tier is permanent.
8. GPL obligations in a distributed product — rare, and decisive when it lands#
Pylint is GPL-2.0-or-later and Semgrep LGPL-2.1-or-later. Irrelevant to running them in your own CI. Decisive if you embed one in software you ship.
Mitigation: for a tools vendor, this is the first question, not the last. See the S3 persona.
Where this category is going#
The consolidation resolved in Python and has not in JavaScript#
This is the clearest finding in the survey and the two sides look nothing alike.
Python consolidated. Ruff at 66.7M weekly downloads against Pylint’s 10.3M and Flake8’s 9.8M — roughly three to one over the nearest. One tool absorbed Flake8, its plugins, isort, pyupgrade and Bandit’s rules, and the ecosystem moved.
JavaScript did not. ESLint at 160.1M against Biome’s 14.3M and oxlint’s 19.4M — five times both challengers combined, and more downloads than Prettier. The consolidation argument is identical; the outcome is not.
The asymmetry is explained by what each ecosystem’s incumbent was. Flake8 is a wrapper around three finished tools with a plugin ecosystem that is large but mostly reimplementable — and Ruff reimplemented it. ESLint’s plugin ecosystem includes type-aware rules that require the TypeScript compiler, framework rules maintained by the frameworks, and thousands of internal packages. Reimplementing it is not a bigger version of the same job; it is a different job.
The forecast that follows: Python’s consolidation is largely complete and Ruff’s remaining growth is in checks Pylint currently owns, which it can only reach by giving up the per-file model that makes it fast. JavaScript will keep a two-tier arrangement — fast Rust pass plus ESLint for the rules that need types and plugins — rather than resolving to one tool.
The formatter/linter split is being enacted by the projects themselves#
Stylelint removed its stylistic rules in v15 and directed users to a formatter. ESLint
still ships indent, semi and quotes but points at @stylistic. Ruff separates
ruff format from ruff check in the same binary.
This survey and 1.253 exist because of that split, and the direction is settled: layout is a formatter’s job and correctness is a linter’s. The remaining formatting rules in ESLint are a compatibility fossil, not a live design position.
Type-aware analysis is the boundary that has not moved#
The rules with no fast implementation are the ones needing a type system, and that has been
true for years. It is why oxlint scopes itself as it does, why Biome cannot match
typescript-eslint, and why Ruff has not reimplemented Pylint’s inference checks.
Watch this boundary rather than the speed numbers — a fast type checker that a linter can query cheaply would change the category’s shape more than any linter release. That work is 1.251’s subject, and it is where the interesting movement is.
What would falsify this reading#
- Ruff shipping a plugin interface. It would remove the one structural reason to stay on Flake8, and would mean Astral had found a way to pay for extensibility without paying for it in speed.
- Biome or oxlint achieving usable type-aware rules, which would mean bundling or reimplementing type checking and would collapse the two-tier forecast.
- ESLint’s download share actually falling. It has not; a forecast built on its stability should be checked against the registry rather than assumed.
Viability — Biome, oxlint, and the single-purpose tools#
Biome#
Governance: a community project with a core team, funded through Open Collective, after being forked from Rome when the company behind Rome shut down.
Signals: 2.5.11 released 2026-08-27, pushed 2026-08-29, 25,674 stars, 518 open issues, 14.3M weekly downloads.
The origin story is the viability story, and it is reassuring rather than alarming. Rome was a company-backed toolchain; the company failed; the project was forked and continued by the community and is now shipping weekly under Apache-2.0. That is a project that has already survived the failure mode people worry about.
Its risk is position, not survival. At a tenth of ESLint’s downloads and now behind oxlint’s, Biome is competing for a slot — “the fast JS linter” — that another project currently occupies more of. The consolidation thesis it was built on has not resolved in JavaScript the way Ruff’s did in Python.
Migration cost if it stops: moderate. Its rules are grouped by intent rather than named after their origins, so a Biome config does not map back onto an ESLint config the way a Ruff config maps onto Flake8’s. That is a nicer configuration to read and a worse one to leave.
oxlint#
Signals: 1.80.0 released 2026-08-24, oxc-project/oxc pushed 2026-08-29, 22,550 stars,
814 open issues, 19.4M weekly downloads.
oxlint is a component of a larger project, and that is the fact that matters. Oxc is a whole JavaScript toolchain — parser, resolver, transformer, minifier — and the linter is one output of it. Several other projects depend on Oxc’s parser, so the parser’s viability is broader than the linter’s popularity.
Its scoping decision protects it. By declining type-aware rules and positioning as a pre-filter rather than a replacement, oxlint has taken a job it can finish. A tool that must reach parity with ESLint has an unbounded task; a tool that runs the cheap rules fast does not.
Migration cost if it stops: low, and this is its real strategic advantage. Because it is additive — installed alongside ESLint rather than instead of it — removing it means deleting a CI step. Nothing depends on it. That asymmetry also explains its download lead over Biome better than quality does.
Bandit, Semgrep, Vulture, Stylelint#
Bandit — PyCQA, Apache-2.0, pushed 2026-08-29, 259 open issues. Community-governed and
current. Its risk is redundancy rather than abandonment: Ruff’s S prefix ports its rules,
and the reasons to keep it (baseline, confidence scoring, report formats) are real but
narrow.
Semgrep — the only open-core product here. LGPL-2.1, 16,438 stars, pushed 2026-08-28. The viability question is not whether the company survives but where the line moves: cross-file data-flow analysis is already a paid tier, and an open-core boundary is a business decision that can be revisited. Depend on the pattern-matching engine; do not build a process around a free capability that could be repositioned.
Vulture — MIT, one primary maintainer, pushed 2026-04-30, four months quiet at time of reading, 71 open issues. The quietest project in this survey. Bus-factor risk is real and consequence is low: it is a small tool used occasionally, not a CI dependency, and its output is a list of candidates rather than something a codebase is built around.
Stylelint — MIT, 11,516 stars, pushed 2026-08-27, 136 open issues. Healthy, and notable strategically for having deleted its stylistic rules in v15 and told users to use a formatter. A project that narrows its own scope deliberately is one that understands what it is for.
Viability — the extensible incumbents: ESLint and Flake8#
Treated together because they face the same question. Both are slower than the Rust tools, both survive on an ecosystem the Rust tools cannot reimplement, and the question for each is whether that moat holds.
ESLint#
Governance: OpenJS Foundation. That is the strongest governance in this survey — not a company, not one maintainer.
Signals: 10.9.1 released 2026-08-24, pushed 2026-08-29, 27,489 stars, 137 open issues, 160.1M weekly downloads.
137 open issues on a project at that scale is remarkable and is the number to read: this is a tracker being actively kept, not a project coasting.
The moat is the plugin ecosystem, and it is not eroding. 160.1M weekly against oxlint’s 19.4M and Biome’s 14.3M — five times both challengers combined. The reason is structural, established in S2: running an ESLint plugin means embedding a JavaScript runtime and calling into it per node, which costs most of what a Rust rewrite is for. The challengers are not declining to solve this; solving it would make them ESLint.
Its real risk is different from decline. It is that the fast pass moves to a Rust tool and ESLint becomes the slow second stage that runs only in CI — still installed, still essential for type-aware and framework rules, but no longer the thing developers interact with. That is a demotion in position, not in viability, and it does not threaten anyone depending on it.
Verdict: the safest dependency in this survey.
Flake8#
Governance: PyCQA, a community organization with several maintainers.
Signals: 7.3.0 released 2025-06-20 — fourteen months. 9.8M weekly downloads, 3,823 stars, 23 open issues. Its components pyflakes 3.4.0 and pycodestyle 2.14.0 released the same day, at 11.4M and 15.2M weekly.
Read the release gap correctly. 23 open issues is the counter-evidence to abandonment: at ten million weekly downloads that is a tracker somebody empties. Flake8 wraps three tools whose scope stopped changing, and it has done that job the same way for years. Stable is not stalled, and the test is not cadence but whether anyone answers.
Contrast the dead tool in this family, found by 1.253: Blue, no release since
2022, no commits since February 2024, and pinned to black==22.1.0 so tightly it cannot be
installed beside a current Black. That is what death looks like. Flake8 does not resemble it.
But it is in decline as a choice, which is different from being at risk. Ruff has three times its downloads and covers its default rules plus its popular plugins. New projects start on Ruff. Flake8’s remaining constituency is codebases with a plugin that has no Ruff equivalent — a real constituency, and a shrinking one.
Verdict: safe to keep, not to start. If you are on it because of a plugin, you are on it for the right reason. If you are on it because migrating never reached the top of the list, that is fine too — this is not urgent.
Viability — Ruff#
Governance: Astral, a venture-funded company. Ruff is MIT and the copyright is distributed across contributors, but direction is set by one company.
Signals, 2026-08-29: 0.16.5 released 2026-08-27, repository pushed the day of reading, 49,385 stars, 2,154 open issues, 66.7M weekly downloads.
The company question, kept in its lane#
Astral is venture-funded and has not, at time of writing, shipped the paid product that funding implies. That is a fact about the company, and the temptation is to turn it into a verdict on the tool. It is not one.
What it bounds is survivability, not quality: if Astral changed direction, Ruff is MIT with a large contributor base and 66.7M weekly downloads, which is fork-able and would be forked. The realistic risk is not disappearance but stall — a tool that keeps working while nobody adds the rule you need.
Set against that: Ruff is now infrastructure for a large share of the Python ecosystem, and Astral’s other tools depend on the same parser. The incentive to keep it alive is structural rather than promotional.
The real risk is the version contract, and it is present tense#
Four years and still 0.16.x. This is not a curiosity, it is the operational risk that shows up in your CI:
- minor bumps add rules; added rules mean new findings on unchanged code
- a rule’s behavior can change between minors without a major-version signal
- there is no deprecation window that a 0.x version number promises
Mitigation is cheap and non-negotiable: pin the exact version in CI and pre-commit, and review a bump as a change rather than a dependency update. A team that floats Ruff will eventually have a red build nobody caused.
What happens if it stops#
Better than most. Ruff’s rules are deliberately named after the tools they came from, so a configuration is legible as a Flake8-plus-plugins configuration and migrating back is mechanical. The lock-in is low because Ruff chose compatibility as its migration story, and that choice cuts both ways.
Verdict#
Safe to adopt, with the version pinned. The dependency risk is smaller than the pre-1.0 version number suggests. The operational risk is the one to plan for.