Open any codebase that has been in active development for more than two years, and you will find something unsettling hiding in plain sight: 10-30% of the code does absolutely nothing. It is never executed, never referenced, never needed. It just sits there, consuming cognitive overhead, inflating build times, and misleading every engineer who reads it.
This is dead code, and your codebase almost certainly has far more of it than you realize.
The problem is not that dead code exists---every evolving codebase accumulates it naturally. The problem is that most teams dramatically underestimate how much they have, where it hides, and how much it costs. Standard tooling catches only the most obvious cases. The subtler forms---especially code hidden behind permanently-on or permanently-off feature flags---evade detection entirely.
What qualifies as dead code?
Dead code is any code that exists in the repository but is never executed in production. This definition is broader than most engineers assume. Dead code is not just commented-out functions or unreachable branches after a return statement. It encompasses a spectrum of waste that accumulates through normal development.
Unreachable code
The most obvious form: code that cannot possibly execute due to control flow. A function that returns before reaching subsequent logic, a branch guarded by a condition that can never be true, or exception handlers for exceptions that are never thrown.
def process_order(order):
if not order.is_valid():
return None
result = calculate_total(order)
return result
# Dead: unreachable after return
send_analytics_event("order_processed")
log_order_details(order)
Most compilers and linters catch this type, but it still appears in surprisingly large codebases---particularly in dynamically typed languages where tooling support is weaker.
Unused exports and public APIs
Functions, classes, and modules that are exported or declared public but never imported or referenced anywhere in the codebase. These often accumulate when features are removed but their utility functions survive, or when APIs are deprecated in favor of new versions but never deleted.
// utils/formatting.ts
// This function was used by the old dashboard, which was replaced 8 months ago
export function formatLegacyMetric(value: number): string {
return `${(value * 100).toFixed(2)}%`;
}
// Still exported, still maintained, still tested... but never called
Unused dependencies
Packages declared in package.json, go.mod, requirements.txt, or Gemfile that are never actually imported. In mature projects, it is common to find a significant portion of declared dependencies that are unused, inflating install times, increasing attack surface, and triggering false positive security alerts.
Dead imports
Import statements referencing modules that are used nowhere in the file. Most linters catch these, but in languages with side-effect imports (Python's import triggering module-level code, for example), distinguishing dead imports from intentional side-effect imports requires deeper analysis.
Orphaned files
Entire files that are not referenced by any other file in the project. Test fixtures for deleted features, configuration files for removed services, migration scripts that have been superseded, utility modules whose consumers were removed. These files pass every lint check because they are syntactically valid---they are just never used.
Flag-controlled dead paths
This is the category that standard tools miss almost entirely. Code paths guarded by feature flags that have been permanently set to one value for weeks, months, or years. The code is syntactically reachable and may even appear in coverage reports (if tests exercise both flag states), but in production, one branch has been dead for months.
func GetPricingPage(ctx context.Context, user User) Page {
if featureFlags.IsEnabled("new_pricing_v2") {
// This branch has been 100% enabled for 9 months
return renderNewPricing(ctx, user)
}
// Dead in production: this code has not executed since March
// But no static analysis tool will flag it
return renderLegacyPricing(ctx, user)
}
We will return to this category in depth because it represents the single largest source of dead code that conventional tools fail to detect.
The scale of the problem
If you have never conducted a systematic dead code analysis, the numbers will surprise you.
Industry data on dead code prevalence
| Codebase Characteristic | Estimated Dead Code | Primary Sources |
|---|---|---|
| Startup (< 2 years) | 5-10% | Unused imports, dead experiments |
| Growth-stage (2-5 years) | 15-25% | Orphaned features, deprecated APIs, flag debt |
| Enterprise (5+ years) | 20-35% | All of the above plus legacy integrations |
| Post-acquisition | 30-50% | Duplicate systems, abandoned integration attempts |
Analysis of open-source repositories consistently shows that a significant percentage of code in mature projects is dead. Private enterprise codebases, which undergo less community scrutiny, tend to be worse.
The compounding cost
Dead code is not free to maintain. It has concrete, measurable costs:
Build and CI time: Dead code must be compiled, bundled, and sometimes tested. For large codebases, dead code can add 5-15 minutes to build times and inflate JavaScript bundles by hundreds of kilobytes.
Cognitive load: Every function a developer reads while navigating unfamiliar code costs 30-60 seconds of comprehension time. If 20% of the code they encounter is dead, they are wasting 20% of their comprehension effort on code that does not matter.
False signals in search: When engineers search the codebase for usage patterns or examples, dead code pollutes results. A developer searching for how the pricing API is called may find three examples---two of which are dead code using deprecated patterns.
Dependency confusion: Dead code that imports unused dependencies prevents those dependencies from being removed, which in turn prevents vulnerability remediation and version upgrades.
Test burden: Dead code with tests means tests that verify nothing useful. These tests consume CI time, create maintenance burden, and provide a false sense of coverage.
Quantifying the impact
For a 500,000-line codebase with 20% dead code, the costs span multiple categories:
Rough estimates based on our experience:
| Impact Area | Annual Cost Estimate |
|---|---|
| Wasted CI/build time | Meaningful (compute costs + developer wait time) |
| Cognitive overhead | Significant (developer time navigating dead code) |
| Unnecessary dependency maintenance | Moderate |
| False-positive security alerts from dead dependencies | Moderate (investigation time) |
| Unnecessary test maintenance | Moderate |
These costs scale with codebase size and team size. For larger codebases with many engineers, the cumulative impact is substantial.
Detection tools by language
Fortunately, the tooling ecosystem for dead code detection has matured significantly. Here is a practical guide organized by language, including the strengths and blind spots of each tool.
TypeScript / JavaScript
knip --- The current gold standard for JavaScript/TypeScript dead code detection. Knip finds unused files, unused exports, unused dependencies, and unused types across your entire project. It understands TypeScript's type system and can trace references through re-exports.
# Install and run
npx knip
# Output: unused files, unused exports, unused dependencies
# with file paths and specific export names
Strengths: Comprehensive; handles monorepos; understands TypeScript; detects unused dependencies. Blind spots: Cannot detect flag-controlled dead paths; does not analyze runtime behavior.
ts-prune --- Focused specifically on unused TypeScript exports. Faster and simpler than knip but less comprehensive.
npx ts-prune
# Output: list of exported symbols with zero references
webpack-bundle-analyzer / source-map-explorer --- While not dead code detectors per se, these tools visualize what code ends up in your production bundle. Large chunks of unused code become visually obvious.
Python
vulture --- Finds unused code in Python programs. Detects unused imports, variables, functions, classes, and attributes. Supports whitelisting for intentional dynamic usage.
pip install vulture
vulture myproject/ --min-confidence 80
Strengths: Fast; low false positive rate with confidence scoring. Blind spots: Struggles with dynamic attribute access (common in Django, Flask); misses flag-controlled dead paths.
dead --- A newer tool specifically focused on finding dead Python code through import graph analysis.
pip install dead
dead
Coverage.py with branch analysis --- Running your test suite with coverage run --branch and then analyzing uncovered branches can reveal code that is never exercised, though this conflates "untested" with "dead."
Go
deadcode (official Go tool) --- Part of the Go tools suite, deadcode reports unreachable functions by analyzing the call graph. It understands Go's interface dispatch and is remarkably accurate.
go install golang.org/x/tools/cmd/deadcode@latest
deadcode ./...
Strengths: Whole-program analysis; understands interfaces; low false positive rate. Blind spots: Cannot analyze code behind build tags or dynamically dispatched calls via reflect.
staticcheck --- While primarily a linter, staticcheck includes several dead code checks: unused functions (U1000), unused parameters, and unreachable code.
staticcheck ./...
Java
PMD --- A source code analyzer with rules for dead code detection. The UnusedPrivateMethod, UnusedLocalVariable, and UnusedFormalParameter rules catch common dead code patterns.
SpotBugs --- Bytecode analysis that can find dead code patterns invisible to source-level tools, including unreachable catch blocks and dead store elimination.
IntelliJ IDEA inspection --- The "Unused declaration" inspection in IntelliJ is surprisingly thorough and can be run in batch mode via inspect.sh for CI integration.
Rust
Rust's compiler is notably aggressive about dead code detection. The #[warn(dead_code)] lint (enabled by default) catches unused functions, methods, struct fields, enum variants, and more. For Rust projects, the built-in tooling is often sufficient.
cargo build 2>&1 | grep "warning: .* is never"
Language-agnostic: tree-sitter
For polyglot codebases or languages without mature dead code tools, tree-sitter provides a foundation for building custom detection. Tree-sitter parses source code into concrete syntax trees without requiring compilation, enabling analysis across 40+ languages with a unified approach.
A tree-sitter-based dead code detector can:
- Parse all source files into ASTs
- Build a reference graph (which symbols reference which)
- Identify symbols with zero incoming references
- Report unreferenced symbols as potential dead code
This approach powers several modern code analysis platforms and is particularly effective for detecting dead code patterns that language-specific tools miss---including flag-controlled dead paths.
The hidden dead code behind feature flags
Standard dead code tools analyze code at the syntactic and type-system level. They answer the question: "Is this code reachable from a static analysis perspective?" But they cannot answer a more important question: "Is this code reachable in production given current flag configurations?"
This gap means that feature flags are the single largest blind spot in dead code detection.
Why tools miss flag-controlled dead code
Consider this code:
export function renderDashboard(user: User): JSX.Element {
if (getFlag('redesigned_dashboard')) {
return <NewDashboard user={user} />;
}
return <LegacyDashboard user={user} />;
}
From a static analysis perspective, both NewDashboard and LegacyDashboard are reachable. Both are imported. Both have non-zero reference counts. No dead code tool will flag either component.
But if redesigned_dashboard has been set to true for every user for the past six months, then LegacyDashboard is dead code in every meaningful sense:
- It has not executed in production for six months
- It never will execute unless someone explicitly changes the flag
- It is consuming bundle size, cognitive load, and maintenance effort for zero value
- It may contain bugs that would surface if the flag were accidentally toggled
The scale of flag-controlled dead code
In a typical growth-stage codebase with 50-100 feature flags, a significant portion will be permanently set to one value. Based on what we have seen across codebases, it is common for the majority of flags to have been at a constant value for 90+ days -- meaning one branch of each of those flags is dead code.
If each flag controls an average of 50-200 lines of conditional code, a codebase with 100 flags can easily contain thousands of lines of flag-controlled dead code -- code that no standard tool will detect.
Detecting flag-controlled dead code
Detecting this category of dead code requires combining static code analysis with flag state data. The approach involves three steps:
Step 1: Identify all flag evaluation points. Using AST parsing (tree-sitter is ideal for this), scan the codebase for all calls to your flag evaluation SDK. Map each evaluation to the flag key it references and the code branches it guards.
Step 2: Correlate with flag state data. Query your feature flag service for the current state and history of each flag. Identify flags that have been set to a constant value (100% on or 100% off) for an extended period.
Step 3: Flag the dead branches. For every flag that has been constant for more than your threshold (90 days is a reasonable default), the code branch corresponding to the inactive state is dead code.
This is precisely the approach that FlagShark takes: it uses tree-sitter to parse flag evaluation points across 11 languages, tracks flag lifecycle data, and identifies flag-controlled dead code that standard tools miss entirely.
Why flag-controlled dead code is particularly dangerous
Unlike other forms of dead code, flag-controlled dead code has a unique risk profile:
It is syntactically valid and appears maintained. Engineers see the flag check and assume both branches are intentionally live. They may even update both branches when making related changes, wasting effort on dead code.
It can resurrect unexpectedly. If someone accidentally or intentionally toggles the flag, dead code that has not been maintained in months suddenly becomes live production code. Bugs that accumulated during the dead period surface immediately.
It creates a false sense of rollback capability. Teams keep old code behind flags as a "safety net," believing they can always toggle back. But dead code rots: dependencies change, database schemas migrate, APIs evolve. The rollback path that felt safe six months ago may now cause data corruption.
It multiplies testing complexity. QA teams may test both flag states, doubling test effort for code that never executes. Or worse, they may skip testing the dead branch, creating an invisible risk if the flag ever changes.
A systematic removal strategy
Detecting dead code is only half the battle. Removing it safely requires a systematic approach, especially for flag-controlled dead code where removal involves modifying conditional logic.
Phase 1: Inventory and classify (Week 1)
Run all applicable detection tools across your codebase and consolidate results.
Detection checklist:
| Tool Category | What It Finds | Run Frequency |
|---|---|---|
| Language-specific dead code tools | Unused functions, exports, variables | Weekly in CI |
| Dependency auditors | Unused packages and libraries | Monthly |
| Bundle analyzers | Dead code in production bundles | Per release |
| Coverage analysis | Untested code paths (proxy for dead code) | Weekly |
| Flag lifecycle analysis | Flag-controlled dead paths | Continuous |
Classify each item by type (unreachable, unused export, dead dependency, flag-controlled) and estimated removal effort.
Phase 2: Quick wins first (Week 2-3)
Start with the categories that have the highest confidence and lowest removal risk:
- Dead imports --- Trivial to remove, zero risk, immediate build time improvement
- Unused dependencies --- Remove from package manager config, verify build succeeds
- Orphaned files --- Delete files with zero references, verify build succeeds
- Unused private functions --- Remove functions with no callers within their module
- Commented-out code --- Delete immediately; that is what version control is for
Each of these categories can be addressed with a single PR per batch. Automate where possible: many linters can auto-fix dead imports, and tools like depcheck or go mod tidy handle dependency cleanup automatically.
Phase 3: Flag-controlled dead code (Week 3-6)
This phase requires more care because it involves modifying conditional logic.
For each stale flag:
- Verify the flag has been constant for your threshold period
- Identify all code locations where the flag is evaluated
- Determine which branch to keep (the one matching the current flag state)
- Remove the flag check and the dead branch
- Remove the flag from your flag service
- Run the full test suite
Example transformation:
Before:
def get_search_results(query, user):
if feature_flags.is_enabled("elasticsearch_migration"):
return elasticsearch_client.search(query, user_context=user)
else:
# Dead: flag has been 100% enabled for 7 months
return legacy_sql_search(query, user.id)
After:
def get_search_results(query, user):
return elasticsearch_client.search(query, user_context=user)
After removing the dead branch, also check whether legacy_sql_search has any remaining callers. If not, it is now an unused function---another piece of dead code you can remove.
Phase 4: Structural dead code (Ongoing)
The hardest category: unused public APIs, dead modules in shared libraries, and code that appears live because it is exported but has no real consumers. This requires whole-program analysis and often human judgment.
Approach:
- Use dependency graphs to trace from entry points (HTTP handlers, CLI commands, event handlers) outward
- Any code not reachable from a known entry point is a candidate for removal
- Verify with production profiling or instrumentation before removing: add logging to suspected dead code paths, wait two weeks, confirm zero executions, then remove
Measuring improvement
Dead code removal is not a one-time event. Codebases accumulate dead code continuously as features are built, deprecated, and replaced. Establish ongoing measurement to prevent regression.
Metrics to track
| Metric | Measurement Method | Target |
|---|---|---|
| Dead code percentage | Tool output / total LOC | Below 5% |
| Unused dependency count | Dependency auditor | Zero |
| Orphaned file count | Reference graph analysis | Zero |
| Stale flag count | Flag lifecycle tracking | Below 10% of total flags |
| Bundle size trend | Bundle analyzer per release | Stable or declining |
| Build time trend | CI metrics | Stable or declining |
CI integration
The most effective dead code prevention is automated detection in CI. Configure your pipeline to:
- Fail on new dead imports (most linters support this today)
- Warn on new unused exports (knip, ts-prune)
- Fail on unused dependencies (depcheck, go mod tidy check)
- Report dead code trends (weekly automated scan with trend tracking)
Preventing new dead code
The best dead code is dead code that never gets committed. Establish team norms:
- When removing a feature, remove all code. Do not leave dead branches "just in case." That is what git history is for.
- When a flag ships to 100%, schedule removal. The flag and its dead branch should be removed within 30 days of full rollout.
- When replacing a library, remove the old one entirely. Do not leave deprecated wrappers around both old and new implementations.
- When deleting a consumer, check for orphaned dependencies. Removing a component that imports utility functions may leave those functions with zero references.
Dead code is the silent tax on every engineering team. It slows builds, confuses developers, inflates bundles, and hides security vulnerabilities---all while consuming maintenance effort for zero production value. The tools to detect and remove it exist today across every major language. The gap that remains is flag-controlled dead code: the substantial percentage of your codebase that is syntactically reachable but practically dead because the feature flags guarding it stopped changing months ago.
Audit your codebase this week. Run the detection tools. Check your flag states. The 10-30% of dead code hiding in your repository is not going to remove itself, but the productivity gains from removing it will compound with every sprint that follows.