# Dead-code detection

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](#unreachable-symbols)** | The call and import graph, from declared entry points | A file no entry point imports; a declared dependency nothing uses |
| **[Unreachable statements](#unreachable-statements)** | The control-flow graph of one procedure | A statement after an unconditional `return`; the body of `if (false)` |
| **[Infeasible branches](#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](#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](https://quality.stereobooster.com/linters.md) 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](https://knip.dev)** — 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** — `noUnusedLocals` and
  `noUnusedParameters` in `tsconfig.json` flag 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](https://quality.stereobooster.com/eslint.md) `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`](https://pkg.go.dev/golang.org/x/tools/cmd/deadcode)** (`golang.org/x/tools/cmd/deadcode`). Builds a
  call graph from `main` using **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 and `go:linkname`
  (Donovan 2023)[^donovan2023].
- **[staticcheck](https://staticcheck.dev/)** `U1000` — unused package-internal symbols, as
  part of the broader linter.

### Python

- **[Vulture](https://github.com/jendrikseipp/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](https://github.com/bnjbvr/cargo-machete)** — scans manifests and source for unused
  dependencies; fast but imprecise (no compilation).
- **[cargo-udeps](https://github.com/est31/cargo-udeps)** — compiles the crate and infers usage from
  build artifacts; precise but slower, and needs the nightly
  toolchain. **[cargo-shear](https://github.com/Boshen/cargo-shear)** is a newer fast option.
- The compiler's own `dead_code` lint already catches most
  genuinely unused `pub`/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](https://github.com/Guardsquare/proguard)** / **[R8](https://developer.android.com/build/shrink-code)** 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`](https://pkg.go.dev/cmd/vet)** — the `unreachable` analyzer, 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: false` turns the editor's
  gray-out into a compile error; the
  [TypeScript recipe](https://quality.stereobooster.com/typescript.md) sets it.
- **ESLint** `no-unreachable`, with `no-unsafe-finally` and
  `no-fallthrough` for 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](https://quality.stereobooster.com/symbolic-execution.md)); an abstract
interpreter proves a branch dead when the abstract values reaching
the test exclude one outcome
([abstract interpretation](https://quality.stereobooster.com/abstract-interpretation.md)); 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)[^maranget2007]. The same algorithm
run the other way reports *missing* cases, the check
[static types](https://quality.stereobooster.com/static-types.md) 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](https://github.com/python/mypy)** `--warn-unreachable` and
  **[pyright](https://github.com/microsoft/pyright)** `reportUnreachable` — 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 `never` marks 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](https://clang-analyzer.llvm.org/)** —
  path-sensitive analysis for C, C++, and Objective-C.
- **[CodeQL](https://codeql.github.com)** — dead-code and constant-condition queries
  written against the dataflow graph.
- **[CBMC](https://www.cprover.org/cbmc/)** — proves a branch unreachable within its
  unwinding bound; the technique is on
  [bounded model checking](https://quality.stereobooster.com/bounded-model-checking.md).

## 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](https://spotbugs.github.io/)** `DLS_DEAD_LOCAL_STORE`.
- **staticcheck** `SA4006` — in Go the usual cause is an `err`
  assigned over.
- **`clang -Wunused-but-set-variable`** (also in GCC), alongside the
  plain unused-variable warnings.
- **[Clippy](https://github.com/rust-lang/rust-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](https://quality.stereobooster.com/monitoring-and-observability.md) —
  that page's scope is service health, and it puts product analytics
  outside it.
- **Coverage, read in the one direction it supports.** A
  [coverage](https://quality.stereobooster.com/coverage.md) 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)[^ramanathan2020].

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)[^ramanathan2020]. 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)[^malavolta2023].

## 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 `match` arms are
  decided rather than estimated, so each finding is a fact about the
  code, and `allowUnreachableCode: 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)[^chakraborty2022].
- **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)[^antal2018].
- **Soundness is achievable but bounded.** Go's `deadcode` is sound
  for dynamic dispatch via RTA: blank cells in its reachability table
  are genuinely dead, modulo assembly and `go:linkname`
  (Donovan 2023)[^donovan2023].
- **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)[^maranget2007].
- **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)[^eder2012].
- **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)[^sotovalero2021].
- **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)[^ramanathan2020].
- **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)[^malavolta2023].

## Classification

- **Quality dimensions:** Maintainability, Functionality (a statement after an early `return` is 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](https://quality.stereobooster.com/maintainability.md) · Quality dimensions
- [Linters](https://quality.stereobooster.com/linters.md) · Methods
- [Refactoring practice](https://quality.stereobooster.com/refactoring-practice.md) · Methods
- [Static analysis](https://quality.stereobooster.com/static-analysis.md) · Methods
- [Taint analysis](https://quality.stereobooster.com/taint-analysis.md) · Methods
- [TypeScript](https://quality.stereobooster.com/typescript.md) · Recipes
- [How AI fits into software quality](https://quality.stereobooster.com/ai.md) · AI

## References

[^donovan2023]: Donovan, Alan. 2023. *[Finding Unreachable Functions with Deadcode](https://go.dev/blog/deadcode)*. The Go Blog. <https://go.dev/blog/deadcode>.
[^maranget2007]: Maranget, Luc. 2007. "[Warnings for Pattern Matching](http://moscova.inria.fr/~maranget/papers/warn/warn.pdf)." *Journal of Functional Programming* 17 (3): 387–421. <https://doi.org/10.1017/S0956796807006223>.
[^ramanathan2020]: Ramanathan, Murali Krishna, Lazaro Clapp, Rajkishore Barik, and Manu Sridharan. 2020. "[Piranha: Reducing Feature Flag Debt at Uber](https://manu.sridharan.net/files/ICSE20-SEIP-Piranha.pdf)." *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>.
[^malavolta2023]: Malavolta, Ivano, Kishan Nirghin, Gian Luca Scoccia, et al. 2023. "[JavaScript Dead Code Identification, Elimination, and Empirical Assessment](https://arxiv.org/pdf/2308.16729)." *IEEE Transactions on Software Engineering* 49 (9): 3692–714. <https://doi.org/10.1109/TSE.2023.3267848>.
[^chakraborty2022]: Chakraborty, Madhurima, Renzo Olivares, Manu Sridharan, and Behnaz Hassanshahi. 2022. "[Automatic Root Cause Quantification for Missing Edges in JavaScript Call Graphs](https://arxiv.org/pdf/2205.06780)." *36th European Conference on Object-Oriented Programming (ECOOP 2022)*, 3:1–28. <https://doi.org/10.4230/LIPIcs.ECOOP.2022.3>.
[^antal2018]: 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](https://arxiv.org/pdf/2405.07206)." *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>.
[^eder2012]: Eder, Sebastian, Maximilian Junker, Elmar Jürgens, Benedikt Hauptmann, Rudolf Vaas, and Karl-Heinz Prommer. 2012. "[How Much Does Unused Code Matter for Maintenance?](https://teamscale.com/hubfs/26978363/Publications/2012-how-much-does-unused-code-matter-for-maintenance.pdf)" *Proceedings of the 34th International Conference on Software Engineering (ICSE '12)*, 1102–11. <https://doi.org/10.1109/ICSE.2012.6227109>.
[^sotovalero2021]: Soto-Valero, César, Nicolas Harrand, Martin Monperrus, and Benoit Baudry. 2021. "[A Comprehensive Study of Bloated Dependencies in the Maven Ecosystem](https://arxiv.org/pdf/2001.07808)." *Empirical Software Engineering* 26 (3): 45. <https://doi.org/10.1007/s10664-020-09914-8>.

## Acronyms

- DCE — dead-code elimination
- RTA — rapid type analysis
