Dead-code detection finds code no execution can run and values no execution can read, without running the program. It builds a model of what the code can do, finds the parts the model can never enter, and reports them for deletion. The payoff is not disk space: code nothing reaches is still read, reviewed, compiled, carried through every refactoring, and shipped.
What "can never enter" means depends on the model, and each model has its own reach and its own blind spot.
What it catches¶
| Kind of deadness | What the analysis reasons over | Example finding |
|---|---|---|
| Unreachable symbols | The call and import graph, from declared entry points | A file no entry point imports; a declared dependency nothing uses |
| Unreachable statements | The control-flow graph of one procedure | A statement after an unconditional return; the body of if (false) |
| Infeasible branches | The values that can hold at a program point, and the condition a path must satisfy to reach it | A condition that always evaluates the same way; a match arm every value already matched |
| Dead stores | Which variables can still be read after each program point | An error assigned to a variable and overwritten before anything checks it |
Symbol-level detection is what the phrase usually names, and the only level with dedicated per-ecosystem tooling. The rest ship as compiler diagnostics and lint rules, most already present in a standard toolchain and off only by configuration.
Every method here answers whether code can run. Whether it ever does run in a deployment is a different question, and static analysis is the wrong instrument for it.
Dead-code elimination (DCE) is a distinct compiler optimization that
strips unreachable code from a build artifact (tree-shaking,
--gc-sections, the Go linker's pruning). DCE makes the binary
smaller and runs every build; the methods here make the source
smaller and run when a human decides to clean up. Both compute
reachability.
Unreachable symbols¶
The analysis takes a set of declared entry points — main, a
package's public API, a framework's route files — builds the call
and import graph, and reports what the graph does not reach: an
unused file, an exported symbol or enum member no other module
imports, a function no path from an entry point reaches, a declared
dependency nothing uses. The inverse finding is worth more than any
of them: a package imported but never declared is a latent bug rather
than cleanup, working today only because a sibling dependency happens
to pull it in.
This is the level a file-at-a-time linter cannot
get to. ESLint's no-unused-vars sees one file, so an exported
symbol imported somewhere looks used even when nothing in the
program ever reaches that import.
Every edge the graph misses turns live code into a finding.
Reflection, string-keyed dispatch, dynamic import(),
dependency-injection containers, and framework naming conventions
all produce calls no static builder sees. Detecting an unused export
is the settled part; deciding one is safe to delete needs the dynamic
edges and the public-API contract, neither of which is in the graph.
Symbol-level tooling by ecosystem¶
JavaScript / TypeScript¶
- knip — finds unused files, dependencies, exports, types, and enum/class members in one pass, with a plugin system that auto-detects ~150 frameworks and test runners.
- The TypeScript compiler itself —
noUnusedLocalsandnoUnusedParametersintsconfig.jsonflag unused local variables and function parameters as part of the normal type check, no extra tool required. Function-scoped, not whole-program: they catch a dead local, not a dead export. - ESLint
no-unused-vars(or@typescript-eslint/no-unused-vars) — the same file-local granularity as the compiler flags, with finer control (e.g.argsIgnorePattern: "^_"). The compiler flags and the lint rule are the file-local complement to knip's whole-program reachability, not a substitute.
Go¶
deadcode(golang.org/x/tools/cmd/deadcode). Builds a call graph frommainusing Rapid Type Analysis (RTA) and reports unreachable functions grouped by package. Sound with respect to dynamic dispatch — a function it calls dead genuinely cannot be reached, modulo assembly andgo:linkname(Donovan 2023)1.- staticcheck
U1000— unused package-internal symbols, as part of the broader linter.
Python¶
- Vulture. Finds unused code and assigns each finding a
60–100% confidence rather than a binary verdict, because
Python's dynamism (
getattr, decorators, reflection) makes certainty impossible. Low-confidence findings are leads rather than verified conclusions.
Rust¶
- cargo-machete — scans manifests and source for unused dependencies; fast but imprecise (no compilation).
- cargo-udeps — compiles the crate and infers usage from build artifacts; precise but slower, and needs the nightly toolchain. cargo-shear is a newer fast option.
- The compiler's own
dead_codelint already catches most genuinely unusedpub/private items, so the cargo add-ons focus on the dependency layer the compiler doesn't police.
Java / Kotlin¶
- IDE inspections (IntelliJ "unused declaration") and ProGuard / R8 shrinkers report and strip unreachable code — the latter blur into DCE proper.
Unreachable statements¶
Inside one procedure the control-flow graph settles the question
outright. A statement after an unconditional return, throw,
break, or continue has no path to it from the procedure's entry.
Java makes an unreachable statement a compile error, under a
reachability rule the language specification defines. The rule is
deliberately conservative in one direction — if (false) { … }
compiles, so a constant can switch a block off, while
while (false) { … } does not.
- javac — reports it as a compile error; nothing to enable.
go vet— theunreachableanalyzer, in the standard toolchain.clang -Wunreachable-code(and-Wunreachable-code-aggressive). GCC still accepts the flag, but the analysis behind it was removed and it now does nothing.- TypeScript —
allowUnreachableCode: falseturns the editor's gray-out into a compile error; the TypeScript recipe sets it. - ESLint
no-unreachable, withno-unsafe-finallyandno-fallthroughfor the control-flow shapes that produce it.
Infeasible branches¶
A branch can sit on a control-flow path and still be impossible: nothing about the graph rules it out, but no input satisfies the condition guarding it. Settling that means reasoning about values — what can hold at the test, and whether anything satisfies the condition a path needs. In general it is undecidable, so tools answer partially and by different routes. A symbolic engine solves the condition for the path it is on (symbolic execution); an abstract interpreter proves a branch dead when the abstract values reaching the test exclude one outcome (abstract interpretation); a type checker gets there without extra work whenever the type it computes for a variable leaves no value that could inhabit it.
One case is fully decided. A useless clause in an ML-style pattern match is one no value reaches, because the clauses before it already match everything it would, and whether a clause is useless is computed rather than estimated (Maranget 2007)2. The same algorithm run the other way reports missing cases, the check static types buy for sum types. The redundancy direction is the one that reports dead code, and a redundant arm and an over-broad arm above it are one finding.
A finding here names a disagreement between what the code guards against and what can arrive at the guard. Which side is wrong is not something the analysis decides.
- Rust
unreachable_patterns, OCaml warning 11, and GHC-Woverlapping-patterns— the pattern-usefulness check, on by default in each. - mypy
--warn-unreachableand pyrightreportUnreachable— both report a branch the computed types exclude. - TypeScript — comparing two types with no value in common is a
type error in its own right, and a variable the checker has reduced
to
nevermarks the code reading it as unreachable. - SonarQube (commercial) — symbolic-execution rules for a condition that always evaluates the same way, and for a boolean expression whose value is already determined.
- The Clang Static Analyzer — path-sensitive analysis for C, C++, and Objective-C.
- CodeQL — dead-code and constant-condition queries written against the dataflow graph.
- CBMC — proves a branch unreachable within its unwinding bound; the technique is on bounded model checking.
Dead stores¶
A dead store is an assignment whose value nothing reads: the variable is overwritten, or leaves scope, before any use. The statement executes, so this is not unreachable code — what is dead is the value. Finding it needs live-variable analysis, a backward dataflow pass computing, for each program point, which variables may still be read before they are next written. An assignment to a variable that is not live immediately after it is a dead store.
The shape that makes this a defect rather than untidiness is a store whose value was meant to be read: an error return assigned and then assigned over before anything checks it, a computed result the next line discards, a parameter reassigned inside a copy the caller never sees.
- SpotBugs
DLS_DEAD_LOCAL_STORE. - staticcheck
SA4006— in Go the usual cause is anerrassigned over. clang -Wunused-but-set-variable(also in GCC), alongside the plain unused-variable warnings.- Clippy and rustc's own
unused_assignments.
What static analysis cannot reach¶
Some code is reachable in principle and never runs in practice. A feature flag left permanently off; a configuration branch no deployment sets; a compatibility shim for a client version that retired; an error path for input the surrounding system can no longer produce. Every analysis on this page reports that code as live, and reports it correctly: an input exists that would reach it, which is the question being asked.
Settling the other question needs a record of what actually ran.
- Execution data from the deployed system. Instrumenting a shipped build and collecting which methods were entered is what separates never-executed code from unreachable code. This is usage measurement over a running system, distinct from the health signals on monitoring and observability — that page's scope is service health, and it puts product analytics outside it.
- Coverage, read in the one direction it supports. A coverage run records which lines executed. What it does not license is the deletion: a line can be both live and untested, so an uncovered line is not a dead line. The usable inference runs the other way, since a covered line clears a symbol-level finding rather than producing one.
- Flag lifecycle tooling. Once a flag's value is settled, the deletion is mechanical. Uber's Piranha takes a flag's name, its expected behavior, and its author, rewrites the syntax tree, and files the resulting diff to that author for review (Ramanathan et al. 2020)3.
Deciding a flag is stale is the part that stays hard, and Piranha's own deployment shows why: its pipelines treat a flag as stale once the flag management system reports it unmodified for longer than a set period, such as 8 weeks, with each team that processes the diffs configuring the exact period (Ramanathan et al. 2020)3. Neither execution data nor coverage is sound in the deletion direction either, so the workable combination is a static finding confirmed by an absence of production usage, with the deletion staged so a mistake stays recoverable. Lacuna takes that shape for JavaScript: at its most conservative setting it replaces a function judged dead with a lazy-loading fallback, so a false positive costs latency instead of breaking the application (Malavolta et al. 2023)4.
When to use, when not¶
Use:
- On any codebase old enough to have accumulated orphaned files and dependencies — the first symbol-level run is almost always high-yield.
- At diff time, as a PR gate, so dead code is caught on the change that orphans it rather than in a periodic cleanup nobody schedules.
- For the
unlisted/phantom-dependency finding specifically — it catches a real latent bug, not just cleanup, and is high confidence. - With the per-procedure checks set to error rather than warning.
Unreachable statements, dead stores, and redundant
matcharms are decided rather than estimated, so each finding is a fact about the code, andallowUnreachableCode: false,no-unreachable,go vet, and the compilers' own unused-assignment lints cost nothing to leave on.
Don't:
- Auto-delete on a reflection-heavy or dynamic codebase without reading each finding. A tool that reports zero dead code there may simply be blind to the dynamic edges — absence of findings is not proof of cleanliness.
- Trust file/export-level findings as much as dependency-level ones. Confidence drops from "declared but never imported" toward "exported but reflection might reach it."
- Treat a wrong entry-point set as a tool bug. A missing entry
point (a CLI
bin, a worker loaded by URL, a framework route) makes its whole subtree read as dead. When a tool flags code that is plainly live, the defect is almost always a missing entry rather than a real finding. - Gate a build on the value-level findings. An always-true condition or an unused export rests on an approximation of the program, and a wrong one blocks the wrong change; those belong in review, where the per-procedure checks belong in CI.
Evidence¶
- Call-graph recall is the binding constraint on symbol-level findings, and on modern code it is low. Measured against dynamic call traces on framework-based web applications, the recall of WALA's approximate call graph by the Reachable Edges metric was 37% for the pessimistic variant of the analysis and 76% for the optimistic one. Dynamic property accesses were the most common root cause of the missed edges, and a lack of models for built-in library functions accounted for much of the rest (Chakraborty et al. 2022)5.
- No single call-graph builder closes the gap. Across five static JavaScript call-graph algorithms compared on 26 WebKit SunSpider benchmark programs and 6 real-world Node.js modules, most of the tools found edges that were missed by all others, and only two were able to analyze up-to-date multi-file Node.js modules (Antal et al. 2018)6.
- Soundness is achievable but bounded. Go's
deadcodeis sound for dynamic dispatch via RTA: blank cells in its reachability table are genuinely dead, modulo assembly andgo:linkname(Donovan 2023)1. - The useless-clause case is computed rather than approximated. Useless clauses and non-exhaustive matches are the two pattern-matching anomalies, and one simple algorithm over the pattern matrix detects both — the usefulness of clause i is checked against the matrix of the clauses before it. It was integrated into the Objective Caml compiler, and the paper shows the same algorithm works for a non-strict language such as Haskell (Maranget 2007)2.
- Unused code is maintained. Over two years of maintenance on a live industrial system, a case study took the never-executed set as its proxy for unnecessary code and measured how much maintenance activity went into that code (Eder et al. 2012)7.
- Unused dependencies, measured across an ecosystem. Of 723,444 dependency relationships across 9,639 Maven Central artifacts, 75.1% were bloated — resolved into the build without being needed to compile or run the artifact. That figure spans the transitive tree rather than declared direct dependencies. In a follow-up with 30 open-source projects, 18 of 21 answered pull requests were merged, removing 131 dependencies (Soto-Valero et al. 2021)8.
- Mechanized deletion lands when the deadness judgment is supplied. Piranha generated cleanup diffs for 1381 stale flags across Uber's apps between December 2017 and May 2019; 65% landed with no changes, and over 85% compiled and passed tests (Ramanathan et al. 2020)3.
- Removing dead code is measurable at the client. Eliminating JavaScript dead code across 30 third-party web apps on a real Android device improved loading time and significantly reduced the bytes transferred over the network (Malavolta et al. 2023)4.
Classification¶
- Quality dimensions: Maintainability, Functionality (a statement after an early
returnis usually a misplaced return rather than dead weight), Security (unused dependencies are still shipped — install scripts, supply-chain surface; removal shrinks it). - Area: Dead code in four senses, split by what the analysis reasons over: the whole-program symbol graph (unused files, exports, functions, dependencies), a procedure's control-flow graph, path conditions and match-arm usefulness, and variable liveness. Per-ecosystem tools plus compiler and linter flags.
- Guarantee: Empirical, Exhaustive (Unreachable statements, Dead stores).
Referenced by¶
- Maintainability · Quality dimensions
- Linters · Methods
- Refactoring practice · Methods
- Static analysis · Methods
- Taint analysis · Methods
- TypeScript · Recipes
- How AI fits into software quality · AI
References¶
-
Donovan, Alan. 2023. Finding Unreachable Functions with Deadcode. The Go Blog. https://go.dev/blog/deadcode. ↩↩
-
Maranget, Luc. 2007. "Warnings for Pattern Matching." Journal of Functional Programming 17 (3): 387–421. https://doi.org/10.1017/S0956796807006223. ↩↩
-
Ramanathan, Murali Krishna, Lazaro Clapp, Rajkishore Barik, and Manu Sridharan. 2020. "Piranha: Reducing Feature Flag Debt at Uber." Proceedings of the ACM/IEEE 42nd International Conference on Software Engineering: Software Engineering in Practice (ICSE-SEIP '20), 221–30. https://doi.org/10.1145/3377813.3381350. ↩↩↩
-
Malavolta, Ivano, Kishan Nirghin, Gian Luca Scoccia, et al. 2023. "JavaScript Dead Code Identification, Elimination, and Empirical Assessment." IEEE Transactions on Software Engineering 49 (9): 3692–714. https://doi.org/10.1109/TSE.2023.3267848. ↩↩
-
Chakraborty, Madhurima, Renzo Olivares, Manu Sridharan, and Behnaz Hassanshahi. 2022. "Automatic Root Cause Quantification for Missing Edges in JavaScript Call Graphs." 36th European Conference on Object-Oriented Programming (ECOOP 2022), 3:1–28. https://doi.org/10.4230/LIPIcs.ECOOP.2022.3. ↩
-
Antal, Gábor, Péter Hegedűs, Zoltán Tóth, Rudolf Ferenc, and Tibor Gyimóthy. 2018. "Static JavaScript Call Graphs: A Comparative Study." Proceedings of the 18th IEEE International Working Conference on Source Code Analysis and Manipulation (SCAM), 177–86. https://doi.org/10.1109/SCAM.2018.00028. ↩
-
Eder, Sebastian, Maximilian Junker, Elmar Jürgens, Benedikt Hauptmann, Rudolf Vaas, and Karl-Heinz Prommer. 2012. "How Much Does Unused Code Matter for Maintenance?" Proceedings of the 34th International Conference on Software Engineering (ICSE '12), 1102–11. https://doi.org/10.1109/ICSE.2012.6227109. ↩
-
Soto-Valero, César, Nicolas Harrand, Martin Monperrus, and Benoit Baudry. 2021. "A Comprehensive Study of Bloated Dependencies in the Maven Ecosystem." Empirical Software Engineering 26 (3): 45. https://doi.org/10.1007/s10664-020-09914-8. ↩