How to Find and Remove Dead Code Safely
Dead code inflates your repo, hides risk, and slows everyone down. Learn how to find dead code with confidence and remove it without breaking production.
Dead code is code that runs nowhere: functions nothing calls, modules nothing imports, feature flags permanently off, whole directories left behind by a migration everyone forgot to finish. It is not harmless. Dead code inflates the surface every new engineer has to read, hides security risk in paths you assume are inactive, breaks your search results with matches that do not matter, and makes the repo feel bigger and scarier than the live system actually is. Removing it is one of the highest-return, lowest-glory things you can do to a codebase. The reason people avoid it is fear: they are not sure it is really dead, and deleting live code breaks production. Here is how to be sure.
Why dead code is worse than it looks
The obvious cost is volume. A repo that is 30 percent dead is a repo where every audit, every search, every onboarding takes 30 percent longer for no reason. But the sharper cost is deception. Dead code lies about what your system does. An engineer reads a function, assumes it matters, and reasons around it. Security scanners flag vulnerabilities in code paths that never execute, so you either waste time fixing dead code or, worse, train yourself to ignore scanner output. And half-dead code, the kind that is called from exactly one place that is itself dead, is the hardest to reason about because it looks alive. Clearing the dead code sharpens every other signal in the repo, which is why I treat it as a prerequisite to serious repo intelligence before a refactor, not an afterthought.
How to find dead code with confidence
Detection comes in layers, from easy and certain to hard and probabilistic.
Static reachability. Start from your real entry points, the routes, the exported public API, the scheduled jobs, and trace what is reachable. Anything the graph never reaches is a strong dead-code candidate. This catches unimported modules and uncalled functions cleanly. A dependency and call graph, which a tool like ReformCode builds from the code, makes this tractable across a large repo instead of a manual guessing game.
Version control archaeology. Cross-reference with git history. Code that has not been touched in years and is not reachable from entry points is almost certainly dead. Age plus unreachability is a high-confidence signal.
Runtime coverage. For the hard cases, the code that static analysis cannot prove dead because of dynamic dispatch, reflection, or string-based routing, instrument production and watch what actually executes over a representative window. Code that never runs across a full business cycle, including the monthly and quarterly jobs, is dead in practice even if the static analyzer cannot prove it.
The trap is dynamic language features. Reflection, eval, string-keyed lookups, and dependency injection can call code that no static analyzer sees as reachable. This is why you never delete on static analysis alone for the ambiguous cases. You confirm with runtime data before you cut.
How to remove it without breaking production
Deletion is a change, and changes carry risk, so you sequence it to keep the risk near zero.
Delete in small, reversible commits. One logical chunk per commit, each easy to revert. Do not delete 40 files in one heroic pull request nobody can review. Small deletions are cheap to undo if you got one wrong.
Deprecate before delete for anything externally reachable. If the dead code is a public API or anything another team might call, mark it deprecated, log any actual calls, and wait a cycle to confirm nothing hits it before you remove it. The log is your proof.
Lean on your tests and your rollback. This is where fast, reliable deploys pay off. If a deletion breaks something, you want to know in minutes and roll back in minutes, which is the same argument I make for owning your deploy pipeline end to end. Cheap rollback makes aggressive deletion safe.
Delete the tests for dead code too. Tests that only cover dead code are also dead. Leaving them makes coverage numbers lie and keeps the corpse partially warm.
Make it a habit, not an event
The reason repos accumulate 30 percent dead code is that nobody deletes as they go. Every migration leaves a tail, every abandoned feature leaves a directory, every flag flip leaves a branch nobody prunes. The fix is to make deletion part of finishing work. When you strangle out a legacy path, as in the strangler pattern for legacy code, the retirement step, actually deleting the old path, is not optional cleanup for later. It is the last step of the job.
Watch the dead-code percentage as a standing metric. When it climbs, migrations are not being finished. A repo that stays lean is one where people delete the moment code goes cold, and the payoff is a codebase where everything you read is something that actually runs. That alone makes every other kind of analysis faster and more honest.