Why static analysis cannot tell you what code is dead

In a monolith, dead code is any endpoint, class, method, job or branch that no production execution path reaches and no external client depends on. Telemetry-driven removal decides what to delete based on observed runtime usage, not on what the source code appears to reference. This process is for backend and platform engineers who own large, long-lived codebases, where the original authors have left and nobody can confirm from memory what is still used.

Static analysis and IDE "find usages" only see references written in the source. Many real callers never appear there:

Caller type Why static analysis misses it
Reflection Class or method names are resolved from strings at runtime
Dependency injection The container wires implementations from configuration or annotations
Dynamic dispatch The concrete override is chosen at runtime, so every override looks equally live
String-based routing URL patterns map to handlers by name or convention
Serialization hooks Frameworks call getters, setters and lifecycle methods implicitly
Templates Views call helpers that the compiler never checks
Scheduled jobs Cron entries or job tables outside the codebase name the entry points
External clients Mobile apps, partners and scripts call endpoints from other repositories

The result is that "zero references" can be wrong in both directions. A method that looks unused may be invoked by a nightly job. A method with many references may only be reachable from routes nobody calls. Deleting live code can break a quarterly report or a partner integration, and the failure may only show up weeks later. That is why you need runtime evidence of usage, gathered over a defined period, before you delete anything.

Who should use telemetry-driven removal

This approach fits teams that meet most of these conditions:

  • They own a large, long-lived codebase. Many people have touched it over time, and some of them have left. Nobody can say with confidence which paths are still exercised.
  • They have production telemetry they can extend. Access logs, a metrics pipeline or structured logging already ship off the hosts. Adding a counter or a log line is a routine change.
  • They have external or indirect consumers. Partners, mobile apps, scripts, reporting jobs or other services call the monolith or read its database. You cannot find these callers by searching your own repository.
  • They have a clear ownership model, or can create one. Someone can sign off on each removal and answer for it.

Platform and developer-experience teams often run the process across a whole codebase. Feature teams apply it to the modules they own. A single engineer can use the same steps on a small scale, for example to retire one legacy API version.

When to use it and when to avoid it

When it should be used

  • Static signals point at code that looks unused, but reflection, dependency injection, configuration-driven routing or external clients mean you cannot be sure.
  • The code has an ongoing cost. It needs security patches or dependency upgrades, it slows builds and tests, or it confuses people during reviews and onboarding.
  • Getting it wrong would be expensive: a broken partner integration, a failed billing run or a compliance gap.
  • You are preparing a larger change, such as extracting a service, upgrading a framework or migrating a database. Every module you remove first is one less module to port.

When it should be avoided

  • The code is trivially dead. Unreferenced private helpers, commented-out blocks and branches behind flags that are permanently hardcoded can be deleted through normal review. Instrumenting them adds delay and gains nothing.
  • Silence would never be meaningful. Disaster-recovery, restore and legal or audit tooling may never run in a normal year. Get written owner confirmation instead of waiting on telemetry.
  • You cannot see every emitter. If some regions, tenants, on-premises installs or batch workers do not ship metrics, a zero is not evidence. Fix coverage first or rely on owner sign-off.
  • The code is about to be replaced anyway. If the whole module is being rewritten or retired on a fixed date, put the effort into the replacement.
  • Instrumentation is not allowed on a path. Some regulated or latency-critical paths may not permit extra logging or metrics. Use code review and owner confirmation for those.

How it works: the process in brief

The method combines cheap static signals with targeted runtime evidence, then removes code in reversible steps:

  1. Inventory. Combine unused-symbol reports, routes with no internal callers and module commit history into a ranked list of candidates. Give each candidate an owner and an expiry date.
  2. Instrument. Add route counters, tombstone markers or sampled coverage for the candidates only. Verify that every region, tenant and worker reports.
  3. Observe. Watch each candidate for a window that matches its risk class. That can be a few weeks for routine traffic, or a full cycle for calendar-driven jobs.
  4. Remove in stages. Deprecate, then disable behind a kill switch, then delete. Each step can be undone more cheaply than the next.
  5. Clean up data. Stop writes, confirm disuse at the database layer, export the data, then drop it.
  6. Make it routine. Track candidates on a dashboard and fail CI when one passes its expiry date.

The sections below cover each step in detail.

Building a ranked inventory of removal candidates

Before collecting that evidence, decide what to watch. Instrumenting everything is noisy and expensive. A ranked inventory focuses telemetry on the code most likely to be removable and assigns each candidate to someone accountable.

Start by collecting three cheap static signals. Each one alone produces false positives, but where they overlap you have a strong lead.

Signal How to collect it Common false positive
Unreferenced symbols IDE or linter "unused" reports, compiler warnings Reflection, dependency injection, serialization hooks
Routes with no internal callers Diff the router table against client code and API specs External partners, mobile apps, cron jobs calling over HTTP
Modules untouched in history Last commit date per directory Stable, heavily used code that simply works

For the history signal, git log -1 --format=%ci -- path/to/module prints the last commit date touching a path. Loop over top-level module directories and sort by that date. Treat old modules as candidates. Age alone is not evidence of disuse.

Merge the signals into one list and score each entry. Weight overlaps highest, and raise the score for code with high maintenance cost, such as security patches, dependency upgrades or slow tests. That puts expensive code near the top, not just obscure code.

Every entry then needs an owner from your ownership file or service catalog, plus an expiry date for its decision. Without an owner, nobody can confirm undocumented consumers. Without an expiry, candidates sit in a spreadsheet indefinitely. Do not delete directly from static output. The list only tells you what to instrument next.

#!/usr/bin/env bash
set -euo pipefail
shopt -s nullglob

ROOT="${1:-src}"               # directory containing top-level modules
CUTOFF="${2:-18 months ago}"    # how old counts as stale
REVIEW_DAYS="${3:-60}"          # time the owner has to make a decision

cutoff_date=$(date -d "$CUTOFF" +%F)
expiry_date=$(date -d "+${REVIEW_DAYS} days" +%F)

printf 'last_commit\tmodule\towner\texpires\n'

for dir in "$ROOT"/*/; do
  dir="${dir%/}"
  # %cs = committer date as YYYY-MM-DD, which sorts correctly as text
  last=$(git log -1 --format=%cs -- "$dir")
  [ -z "$last" ] && continue     # untracked directory, skip it

  # Keep only modules last touched before the cutoff
  if [[ "$last" < "$cutoff_date" ]]; then
    # Owner is "unassigned" until resolved from CODEOWNERS or the service catalog;
    # every row gets an expiry so it cannot sit in the list indefinitely.
    printf '%s\t%s\tunassigned\t%s\n' "$last" "$dir" "$expiry_date"
  fi
done | sort

A bash script that uses git log to find top-level modules last touched before a cutoff date and outputs them oldest first as TSV rows with owner and expiry columns, ready to merge with the other static signals into the ranked inventory.

How usage telemetry works: unused endpoints and code coverage in production

Counting HTTP routes

Start with data you already collect. Access logs or request metrics give you per-route hit counts without new instrumentation. Normalize paths to their route templates first. Otherwise /orders/123 and /orders/124 appear as separate rows, and a heavily used endpoint can look like thousands of rarely hit ones. Prefer the template your router matched. Fall back to regex replacement of numeric IDs and UUIDs only when the router does not expose it. Then break counts down by API key, client ID or user agent. "Ten calls a week" means one thing if it is a health checker and another if it is a partner's quarterly batch job.

Tracing non-HTTP code

Background jobs, internal functions and feature branches need different signals:

Technique What it tells you Overhead Typical use
Tombstone marker A specific call site ran at least once Near zero after first hit Suspected-dead functions
Sampled runtime coverage Which lines ran across a sample of processes Moderate, bounded by sampling Broad sweeps of whole modules
Route metrics Request volume per endpoint and client Low HTTP surface

A tombstone emits once per process per call site. An in-memory set deduplicates it, so the hot path does no work after the first call. Labels should be the call-site ID only. Never use user IDs or raw paths, because unbounded labels inflate metrics storage. Coverage complements tombstones by finding candidates you did not think to mark.

Blind spots

Zero hits only counts if every emitter reports. Check that telemetry covers all regions and tenants, including single-tenant deployments. Include offline or batch workers that rarely run, and processes that log locally but never ship metrics.

import logging
import re
import sys
import threading
from collections import Counter

log = logging.getLogger("tombstone")
_seen: set[str] = set()
_lock = threading.Lock()

def tombstone(site_id: str) -> None:
    # Hot path: after the first hit this is one set lookup, with no lock and no I/O
    if site_id in _seen:
        return
    with _lock:  # two threads hitting the same site at once still emit only once
        if site_id in _seen:
            return
        _seen.add(site_id)
    # The only label is the call-site ID. Never attach user IDs or raw paths.
    log.warning("tombstone hit site=%s", site_id)

UUID = re.compile(r"[0-9a-fA-F]{8}(-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}")

def normalize(path: str) -> str:
    # This is the fallback for when the access log does not record the router's matched template
    segments = path.split("?", 1)[0].split("/")
    return "/".join("{id}" if s.isdigit() else "{uuid}" if UUID.fullmatch(s) else s
                    for s in segments)

def count_routes(lines) -> Counter:
    # Assumed log format: METHOD PATH STATUS CLIENT_ID (e.g. "GET /orders/123 200 partner-batch")
    counts: Counter = Counter()
    for line in lines:
        parts = line.split()
        if len(parts) < 4:
            continue  # skip malformed lines rather than miscounting them
        method, path, _status, client = parts[:4]
        counts[(method, normalize(path), client)] += 1
    return counts

if __name__ == "__main__":
    for (method, route, client), n in sorted(count_routes(sys.stdin).items(), key=lambda kv: kv[1]):
        print(f"{n:>8}  {method:<6} {route:<40} {client}")

A deduplicated tombstone helper that logs each call site once per process, plus an access-log counter that collapses numeric and UUID path segments into route templates and breaks hits down by client, listing the least-used routes first.

Choosing an observation window and when absence of evidence is not enough

Once coverage is complete, the next question is how long to watch. A two-week window proves only that code did not run in those two weeks. Much of a monolith runs on a calendar. Monthly billing and invoicing, quarterly tax filings, annual year-end reports, certificate renewals, and leap-year or fiscal-year-rollover branches can stay silent for months and then become critical on one specific day. Disaster-recovery failover, restore-from-backup tooling, and legal-hold or audit-export paths may never run in a normal year. They exist for the day they are needed. Treating their silence as proof of death is how teams discover a missing endpoint during an incident.

Not every candidate deserves a long window. Classify each one by how often it could plausibly run and what breaks if it is wrong:

Risk class Examples Evidence needed
Trivially safe Unreferenced private helpers, commented-out branches, feature flags fully rolled out and hardcoded None. Delete through normal review
Routine traffic Old API versions, UI endpoints, internal admin pages Two to four weeks of zero hits
Calendar-driven Billing runs, quarterly reports, scheduled batch jobs At least one full cycle plus margin, often 13 months for annual jobs
Rare but critical DR failover, compliance exports, incident tooling Skip telemetry and get written confirmation from the owner

Matching the evidence to the risk class keeps cleanup moving. Long windows go only to code that needs them. Owner sign-off replaces waiting when silence would never be meaningful.

A staged removal workflow: instrument, deprecate, disable, delete

Once a candidate clears its window, move it through four stages. Each stage can be reversed more cheaply than the one after it.

  1. Instrument and observe. Confirm the counter or log line is actually firing in production before you trust a zero. A metric that was never deployed looks exactly like a metric for code nobody calls.
  2. Deprecate. Mark the code deprecated in source. For external consumers, return Deprecation and Sunset response headers or an equivalent warning. Record the client identity on every call. Many clients ignore headers, so this list is the real output of the stage: it tells you whom to contact. Before you set a sunset date, check contracts and SLAs. Some APIs carry notice periods that override your timeline.
  3. Disable. Put the code behind a kill switch that returns an explicit error, such as 410 Gone with a message naming the replacement, and logs each attempt. The handler shown below does this. A clear error points callers to the fix, while a silent failure starts a debugging session somewhere else. Flipping the flag back restores service in seconds.
  4. Delete. Remove the code, tests, configuration and documentation in separate changes so each one gets a focused review.

Risks to check before deleting

  • Shared initializers, event subscribers and migrations can depend on the code with no request ever reaching it. Search for registrations, not just call sites.
  • The kill-switch flag is now dead code itself. Give it an owner and a removal date in the same ticket.
import express, { Request, Response } from "express";

const app = express();

// The existing v1 export implementation. It is unchanged while the endpoint is deprecated.
declare function legacyExportRows(req: Request): unknown[];

// Kill switch. Read from env here for brevity; in production use your flag service
// so it can be flipped back without a deploy. Record its owner and removal date
// in the cleanup ticket for this endpoint.
const legacyExportDisabled = () => process.env.DISABLE_LEGACY_EXPORT === "true";

function logCall(event: string, req: Request): void {
  // Record who is calling so the deprecation stage produces a contact list.
  console.warn(JSON.stringify({
    event,
    endpoint: "GET /v1/reports/export",
    client: req.get("X-Client-Id") ?? "unknown",
    ts: new Date().toISOString(),
  }));
}

app.get("/v1/reports/export", (req: Request, res: Response) => {
  if (legacyExportDisabled()) {
    // Disabled stage: fail loudly with a pointer to the fix, and log the attempt.
    logCall("deprecated_endpoint_blocked", req);
    res.status(410).json({
      error: "gone",
      message: "GET /v1/reports/export is disabled. Use GET /v2/reports/export.",
    });
    return;
  }

  // Deprecated stage: still serve, but advertise the sunset and record the caller.
  logCall("deprecated_endpoint_called", req);
  res.set("Deprecation", "@1793491200"); // 2026-11-01T00:00:00Z
  res.set("Sunset", "Tue, 01 Dec 2026 00:00:00 GMT");
  res.set("Link", '</v2/reports/export>; rel="successor-version"');
  res.json({ rows: legacyExportRows(req) });
});

app.listen(3000);

An Express handler that serves a deprecated endpoint with Deprecation and Sunset headers while logging each caller, then returns an explicit 410 naming the replacement and logs the attempt once the kill switch is on.

Cleaning up database tables, columns and other data leftovers

Retiring the code path does not retire its data. Tables, columns and indexes outlive the code and keep costing storage, vacuum time and migration complexity. Stop writes first, before tackling the remaining reads. Once nothing inserts, updates or deletes, the table's contents are frozen. Any later read activity in the statistics points clearly at a consumer you missed, and the backup you take later is final rather than a snapshot of a moving target. Expect a missed reader to see stale data instead of failing outright, so watch error rates and data-quality alerts during this period.

Confirm disuse at the database layer

Application telemetry only sees your application. Reporting jobs, ad-hoc analyst queries and other services may read the same tables directly. Where the database exposes access counters, use them. In PostgreSQL, pg_stat_user_tables reports seq_scan, idx_scan and the n_tup_ins/n_tup_upd/n_tup_del write counters per table. Snapshot these values, wait through your observation window, and compare. Do not treat the absolute numbers as meaningful on their own.

Treat a flat counter as evidence, not proof. Statistics can be reset manually with pg_stat_reset() or lost after a crash. They are also kept per instance, so reads served by replicas never appear on the primary. Columns have no per-column access counter, so use query logs or pg_stat_statements for those instead.

Before any DROP, export the table or column data and store it with a retention date. Restoring from an export is much faster than reconstructing lost data.

-- Run on the primary AND on every replica: counters are per instance.

-- 1. Note when statistics were last reset. A reset inside your window invalidates it.
SELECT datname, stats_reset
FROM pg_stat_database
WHERE datname = current_database();

-- 2. Baseline: copy the current counters into a snapshot table.
CREATE TABLE IF NOT EXISTS ops_table_usage_snapshot AS
SELECT now() AS captured_at, relid, schemaname, relname,
       seq_scan, coalesce(idx_scan, 0) AS idx_scan,
       n_tup_ins, n_tup_upd, n_tup_del
FROM pg_stat_user_tables
WITH NO DATA;

INSERT INTO ops_table_usage_snapshot
SELECT now(), relid, schemaname, relname,
       seq_scan, coalesce(idx_scan, 0), n_tup_ins, n_tup_upd, n_tup_del
FROM pg_stat_user_tables;

-- 3. After the observation window: compare against the latest baseline.
-- Any non-zero delta means something still touches the table.
-- A negative delta means counters were reset, so restart the window.
SELECT c.schemaname, c.relname,
       c.seq_scan - b.seq_scan                    AS seq_scan_delta,
       coalesce(c.idx_scan, 0) - b.idx_scan       AS idx_scan_delta,
       (c.n_tup_ins - b.n_tup_ins)
     + (c.n_tup_upd - b.n_tup_upd)
     + (c.n_tup_del - b.n_tup_del)              AS writes_delta
FROM pg_stat_user_tables c
JOIN ops_table_usage_snapshot b ON b.relid = c.relid
WHERE b.captured_at = (SELECT max(captured_at) FROM ops_table_usage_snapshot)
  AND c.relname IN ('legacy_invoice_exports', 'legacy_promo_codes');

-- Zero deltas are evidence, not proof. Export before dropping, e.g.:
--   pg_dump -t public.legacy_promo_codes -Fc -f legacy_promo_codes.dump appdb

Snapshot PostgreSQL's pg_stat_user_tables counters, then compute read and write deltas for candidate tables after an observation window, checking stats_reset and negative deltas to catch counter resets.

How it saves engineering time

Dead code costs time in small, recurring ways that are easy to overlook. Removing it with evidence, instead of by guesswork, recovers that time without trading it for incident time.

  • Faster builds and test runs. Every deleted module is code that no longer compiles, lints or runs tests in CI. The same is true for every developer's local loop.
  • Fewer dependency and security upgrades. Code that is gone no longer pins old libraries or needs patching when a vulnerability is announced in something it imports.
  • Less reading and less confusion. Engineers stop tracing through paths that never execute during debugging, reviews and onboarding. Search results and call hierarchies get shorter and more trustworthy.
  • Smaller migrations. Framework upgrades, language version bumps, service extractions and schema migrations all cover less surface area.
  • Fewer incidents caused by cleanup. Telemetry and staged removal catch hidden callers before deletion. The kill switch turns a missed caller into a flag flip, not a revert-and-redeploy under pressure.
  • Less time spent debating. A ranked inventory with owners and expiry dates replaces open-ended discussions about whether something is safe to remove. The data and the named owner decide.
  • Less wasted instrumentation. Risk classes keep you from instrumenting trivially dead code, and from waiting on telemetry for code where only owner sign-off is meaningful.

The next section puts an illustrative number on the ongoing drag and compares the approach with its alternatives.

Alternatives, cost and making cleanup routine

Each option trades certainty against effort, and the right choice depends on how much an incorrect deletion would cost you.

Approach Upfront cost Main risk
Static-only deletion Lowest Misses reflection, config-driven routing and external callers
Delete, rely on tests and rollback Low Tests cover only paths someone wrote tests for; rare callers break after the rollback window
Leave code in place None today Build, test, review and onboarding cost keeps growing
Full runtime coverage High Overhead on every code path, plus large data volumes to analyze

Targeted usage telemetry sits in the middle. You pay for counters on candidates only, but you also pay for months of carrying dead weight during the observation window, and for cleaning up the flags you added to disable code.

Illustrative estimate (assumed numbers, not a benchmark)

annual drag = engineers × hours lost per engineer per week × working weeks

Assume 40 engineers, 0.5 hours each per week lost to slower builds, irrelevant test failures and reading dead paths, and 48 weeks. That gives 40 × 0.5 × 48 = 960 hours per year, or 20 hours per week. If instrumentation, staged removal and flag cleanup cost 140 hours, payback is 140 ÷ 20 = 7 weeks.

Making it routine

Track every candidate as a tombstone on a dashboard. Each entry records its owner, last observed hit and expiry date. Add a CI check or review rule that fails when a tombstone passes its expiry date. That failure forces the owner to either delete the code or extend the date with a written reason.

Ownership is the usual blocker. When several teams touch a module, each one assumes another team will delete it. Assign one named person to make the final delete decision for each candidate.

How OneEnv can help

Disclosure: OneEnv is the product behind this blog.

OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.

Applied to remove dead code monolith, this is where it helps:

Saves time

  • Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
  • One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
  • Changing a shared variable is one edit and one review instead of editing every service's .env by hand.

Saves cost

  • Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
  • Less duplicated configuration to maintain: one shared definition inherited by many services.
  • A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
  • OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.

These are qualitative benefits, not measured results — how much you save depends on how many services share the value.

Want to try it? Start at oneenv.io.