In this guide
Dead code is code that never runs, or runs but whose result nobody uses. Every large codebase accumulates it: feature flags that were fully rolled out but never cleaned up, exports kept "in case", handlers for an API version nobody calls, utilities duplicated after a refactor. It costs more than disk space. Dead code shows up in search results, confuses new engineers, gets updated during migrations for no reason, and gives coding agents plausible-looking functions to call that should not exist.
Finding it is partly a tooling problem and partly an evidence problem. Static analysis is good at finding code that nothing references. It is bad at knowing about reflection, dynamic imports, framework conventions and external callers. Runtime data fills that gap. This guide covers the tools for each major language, how to configure them so the output is trustworthy, and a staged process for removing code without breaking anything.
Key takeaways
- Dead code comes in several kinds: unused files, unused exports, unreachable functions, unused dependencies, stale flags and unused endpoints. Each needs a different tool.
- Use language-aware tools: knip for JavaScript and TypeScript, vulture for Python, deadcode and staticcheck for Go, compiler lints and cargo-machete for Rust.
- Configure entry points carefully. Most false positives come from tools not knowing how your app starts.
- Static analysis finds candidates. Coverage and production telemetry confirm them.
- Remove in stages: mark, observe, delete. Keep deletions small and easy to revert.
- Add a CI check so new dead code does not accumulate after the cleanup.
The kinds of dead code
| Kind | Example | How to find it |
|---|---|---|
| Unused file | src/utils/oldFormatter.ts imported by nothing | Module graph tools (knip, madge) |
| Unused export | export function parseLegacyDate with no importers | knip, ts-prune, IDE references |
| Unreachable function | A Go function no call path from main reaches | deadcode, call graph analysis |
| Unused private symbol | A private method or local variable | Compiler warnings, linters |
| Unused dependency | A package in package.json that nothing imports | knip, depcheck, deptry, cargo-machete |
| Stale feature flag | A flag that has been 100% on for a year | Flag service data plus code search |
| Dead endpoint | A route with zero traffic | Access logs, APM, gateway metrics |
Unused private symbols are the easy case, because the compiler or linter can prove they are unused. Everything else involves some uncertainty, which is why the process around the tools matters.
JavaScript and TypeScript: knip
Knip is the most complete tool in this ecosystem. It finds unused files, unused exports and types, and unused or missing dependencies, and it ships plugins that understand common frameworks and tools (Next.js, Vite, Jest, Vitest, ESLint, Storybook and many more) so it knows their entry points.
npx knip
On a real project, the first run is usually noisy until you tell it how the project is structured.
{
"$schema": "https://unpkg.com/knip@5/schema.json",
"entry": ["src/index.ts", "scripts/*.ts"],
"project": ["src/**/*.{ts,tsx}"],
"ignore": ["src/generated/**"],
"ignoreDependencies": ["@types/node"]
}
Save that as knip.json. For monorepos, knip supports a workspaces key with per-package configuration. Useful flags:
npx knip --include files # only unused files
npx knip --include exports,types # only unused exports
npx knip --production # ignore tests and dev tooling, analyze shipped code only
ts-prune was the common tool for unused exports for years, and you will still see it recommended. Its maintainers have put it in maintenance mode and point users to knip, so prefer knip for new setups. For quick dependency-only checks, depcheck still works.
TypeScript itself catches unused locals and parameters with noUnusedLocals and noUnusedParameters in tsconfig.json, which is worth enabling regardless.
Python: vulture and deptry
Vulture finds unused functions, classes, variables, imports and unreachable code by analyzing the AST across your project.
pip install vulture
vulture src/ --min-confidence 80
Each finding has a confidence score. Unused imports and code after a return get high confidence; unused functions get lower confidence because Python is dynamic. Python frameworks call a lot of code by convention (Django views referenced in URL configs as strings, pytest fixtures, Celery tasks, __dunder__ methods), so you need a whitelist.
# Generate a whitelist of current findings, review it, keep the true positives out
vulture src/ --make-whitelist > vulture_whitelist.py
vulture src/ vulture_whitelist.py
Review the generated whitelist rather than accepting it wholesale, otherwise you have just hidden the dead code you were looking for. For unused, missing and transitive dependencies in pyproject.toml or requirements.txt, use deptry:
pip install deptry
deptry .
Ruff's F401 (unused import) and F841 (unused local variable) rules cover the provable cases quickly.
Go: deadcode and staticcheck
Go has two complementary tools. staticcheck reports unused unexported identifiers with check U1000:
go install honnef.co/go/tools/cmd/staticcheck@latest
staticcheck -checks U1000 ./...
For exported functions, which U1000 cannot judge, the Go team's deadcode tool performs a whole-program reachability analysis from your main packages using Rapid Type Analysis, and reports functions that no call path reaches.
go install golang.org/x/tools/cmd/deadcode@latest
deadcode ./...
deadcode -test ./... # treat tests as entry points too
deadcode -whylive=example.com/app/internal/pricing.Quote ./...
The -whylive flag is useful in reverse: it explains why a function you thought was dead is still reachable, by printing a call path to it. Because deadcode needs main packages, it is most useful for applications. For libraries, the exported API is the product and reachability from main does not apply.
Rust, Java and others
Rust. The compiler's dead_code lint already warns about unused private items. Check that nobody has silenced it broadly with #![allow(dead_code)] at the crate root:
git grep -n 'allow(dead_code)'
For unused dependencies, cargo-machete is fast and works on stable; cargo-udeps is more precise but requires nightly.
cargo install cargo-machete
cargo machete
Java and Kotlin. IntelliJ's "Unused declaration" inspection can be run project-wide (Code, then Inspect Code) and is the most practical option for many teams. PMD has rules such as UnusedPrivateMethod, and Kotlin's compiler warns on unused private members. Frameworks like Spring wire beans by annotation and reflection, so treat findings on annotated classes with caution.
CSS. PurgeCSS, and the content-scanning built into Tailwind, remove unused selectors at build time. Finding unused style files in source still needs a module graph tool.
Any language. Coverage data from your test suite shows lines no test exercises. That is not the same as dead, but low coverage plus no static references is a strong signal.
Static analysis is not proof
Every tool above can be wrong in the same ways. Before you delete, check for:
- Dynamic references:
getattr(module, name),import(`./handlers/${type}`), reflection, dependency injection containers, string-based routing. - Framework conventions: files that are loaded because of where they are, such as Next.js
app/routes, Rails controllers, Django management commands, or migration files. - External callers: public APIs, SDKs, webhooks, other services, scheduled jobs defined in infrastructure config rather than in code.
- Rare paths: year-end jobs, disaster recovery scripts, admin tools used twice a year.
The answer to all four is runtime evidence.
Confirming with runtime data
For anything that is not provably unused, gather evidence that it does not run in production.
- Access logs and APM. Query request counts per route over a period long enough to cover monthly and quarterly jobs. Zero requests over 90 days is much stronger evidence than zero over a week.
- Tombstones. Add a log line or metric increment to the suspected dead code and deploy it. If the counter stays at zero for your observation window, the code is very likely dead.
def legacy_export(request):
metrics.increment('tombstone.legacy_export')
log.warning('tombstone hit: legacy_export', extra={'path': request.path})
...
- Flag service data. Flags that have served a single variant to all traffic for months are candidates for removing both the flag and the losing branch.
- Production coverage. Some languages support collecting coverage from a running service, for example Go's coverage support for binaries built with
go build -cover. It is heavier to set up, but it gives the clearest picture for critical services.
A staged removal process
- Generate candidates with the static tool for your language. Sort by size; large dead files are the best return on effort.
- Triage. For each candidate, check the dynamic reference list above. Mark it as provably dead, probably dead, or alive.
- Delete provably dead code in small PRs grouped by area. One PR per module is easy to review and easy to revert.
- Instrument probably dead code with tombstones and wait out your observation window.
- Delete confirmed code in a separate PR that links to the evidence: the tombstone dashboard, the log query, the flag history.
- Remove the related tests, fixtures, config and docs. Leftover tests for deleted code are themselves dead code.
Write the PR description so a reviewer can verify the claim quickly:
Remove legacy CSV export (src/exports/legacy/*)
Evidence:
- knip reports no importers for src/exports/legacy/index.ts
- Route /api/export/legacy: 0 requests in the last 90 days (APM link)
- Tombstone metric tombstone.legacy_export: 0 hits since deploy on 2026-08-01
Rollback: revert this PR. No data migrations involved.
If you are removing a larger subsystem, the incremental approach in safe refactoring in large codebases applies: remove callers first, then the implementation, then the data.
Keeping it from coming back
After a cleanup, add the tool to CI so new dead code fails the build or at least shows up in review.
# .github/workflows/dead-code.yml
name: dead-code
on: [pull_request]
jobs:
knip:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npx knip
Give every new feature flag an owner and a removal date. Make deleting the old path part of the definition of done for migrations. And treat dead code as an input to mapping technical debt: areas with a lot of it usually have other problems too.
Seeing what is connected
Dead code is easiest to spot when you can see the module graph. A file with no incoming edges, or a cluster that only connects to itself, stands out visually in a way a list of warnings does not. OpenVisio indexes a repository locally in the browser, so you can open the Explore view and inspect a suspected module's dependents before deciding. Agents can do the same check over MCP with get_dependents before proposing a deletion, which is a useful guard when an agent is doing cleanup work.
Dead code removal is low-glamour work with a steady payoff: faster search, shorter onboarding, smaller migrations and fewer wrong turns. Use the right tool per language, trust runtime evidence over intuition, and delete in small, reversible steps.

