tech, developers, and the code underneath

issue 173· essay·

Static analysis is finally worth the false positives

The tools got dramatically better while everyone was ignoring them because of a bad experience in 2015.

A lot of engineers formed their opinion of static analysis from a tool that produced four thousand warnings on first run, 95% of which were noise, and got disabled within a month.

That was an accurate assessment of the tools at the time. The tools are substantially different now and the assessment has not updated.

what changed#

Flow-sensitive analysis became standard. Older linters matched patterns in the syntax tree. Modern analyzers track values through the control flow graph, which means they can tell that a variable was checked for null on line 12 and therefore is not null on line 40. That single capability eliminates the largest source of false positives.

Language servers made it interactive. A warning in your editor while you type is a different product from a report generated in CI. You fix it in context, in seconds, instead of triaging a list a week later.

Type systems absorbed much of it. A lot of what static analysis used to catch is now caught by the compiler in a typed language, for free, with no false positives at all.

The defaults got sane. Modern tools ship with a curated recommended set rather than everything enabled. clippy, ruff, biome, staticcheck and their peers are opinionated about what is worth reporting.

They got fast. Analyzers written in compiled languages run over a large codebase in seconds. Speed matters more than people credit — a check that takes two minutes gets run in CI, and a check that takes two seconds gets run on every save, which is where it actually changes behavior.

what to actually run#

A fast linter with a good default set, on save, in the editor. ruff for Python, clippy for Rust, biome or eslint for JavaScript, staticcheck for Go.

A typechecker in strict mode, in CI. This is the highest-value item on the list for a gradually-typed language, and the non-strict modes permit exactly the holes that make the guarantees unreliable.

A security-focused analyzer if you handle untrusted input. Taint tracking — does data from a request reach a SQL query, a shell command, or a template without sanitization — is the specific capability worth having, and it is a genuinely different analysis from ordinary linting.

A dependency scanner, ranked by reachability if your tooling supports it. A critical CVE in code you never call is lower priority than a medium in your request path, and a scanner that cannot tell you which is which produces a queue nobody reads.

the adoption sequence#

Turning on a full rule set against an existing codebase produces thousands of warnings and gets the tool disabled. The sequence that works:

1. Run it in report-only mode. Get the number. Do not fix anything yet.

2. Enable a small subset that has near-zero false positives and fix those. Usually: unused variables, unreachable code, obviously wrong comparisons, missing awaits. Twenty rules, not four hundred.

3. Make it blocking for new and changed code only. Most tools support this, either natively or through a diff-aware wrapper. This is the key move — the existing violations do not block anyone, and the codebase stops getting worse immediately.

4. Burn down the backlog opportunistically. When you touch a file, fix its warnings. No cleanup sprint, no dedicated project.

5. Add rules gradually, one at a time, each with a burn-down.

Steps three and four are where most adoptions succeed or fail. A tool that blocks the whole team on a pre-existing backlog gets turned off; one that only blocks new violations is uncontroversial.

the rules worth arguing about#

Some checks are genuinely contested and you should decide deliberately rather than accepting the default:

Cyclomatic complexity limits. Sometimes a function is legitimately complex because the domain is. A hard limit produces artificially split functions that are harder to read, not easier.

Line length. Real disagreement, formatter should handle it, not worth a rule.

Naming conventions. Worth enforcing, and pick your convention rather than the tool's default if they differ.

Anything with more than a few percent false positives. A rule that is wrong one time in ten trains people to ignore it, and that habit generalizes to the rules that are right.

the honest limits#

Static analysis finds a specific class of bug: local, syntactic, pattern-matchable. It does not find logic errors, wrong business rules, race conditions in most cases, or performance problems.

It is not a substitute for tests, review, or thought. It is a way to spend zero human attention on the errors that do not require human attention, which frees attention for the ones that do.

That framing — attention allocation rather than bug finding — is the one that makes it worth the setup.

the new reason it matters#

Machine-generated code has a characteristic error profile: plausible, syntactically valid, and wrong in specific recurring ways. Unchecked errors, missing awaits, resource leaks, off-by-one in boundary conditions.

Those are exactly the errors static analysis is good at. Running a strict analyzer over generated code is verification you get for free, and it is one of the few places where the verification bottleneck has an automated answer.

Turn it on.

get README in your inbox

One dispatch, no noise. Tech and developer news, plus the occasional long piece on the craft.

subscribe →