How do you measure dead code without deleting business rules?
Learn how to measure dead code with static reachability, runtime coverage, traffic replay, and explicit tests for rare year-end business rules.

Dead code is not whatever looks old, awkward, or quiet in production. It is code for which you can make a specific claim, backed by evidence, about reachability, observation, and business ownership. Those claims are different, and collapsing them into one label is how a cleanup or rewrite quietly removes the only implementation of an annual adjustment, a dormant contract, or a recovery path.
I have watched teams delete routines because nobody recognized their names, then discover that a scheduler invoked them under a service account on the final working day of the year. The code had no inbound call from the application, no execution in an ordinary traffic sample, and one very real caller. A useful measurement method must survive that case.
The practical answer is to treat suspected dead code as an evidence problem. Static analysis maps what can be called. Runtime coverage records what was called under defined conditions. Job catalogs, calendars, configuration, data values, operator procedures, and traffic archives expose the inputs that neither method sees alone. Deletion comes only after those records agree, an owner accepts the claim, and a parity test shows no behavioral loss.
Dead code has three different meanings
A dead code report should separate impossible paths, unobserved paths, and obsolete behavior. Each category supports a different action. A single percentage called dead code hides the uncertainty that reviewers need to see.
Statically unreachable code has no feasible path from any declared entry point under the analyzer's model. An unreferenced private function in a closed module may fit this category. So may a branch guarded by a condition that the type system proves false. This is the strongest technical signal, but it is only as complete as the entry point list and the analyzer's model of dispatch.
Runtime-unobserved code received no hits during a named observation window and workload. That statement says nothing about other dates, tenants, roles, data ranges, failure modes, or operator actions. Coverage from ordinary web traffic often misses batch jobs, restart handlers, administrative screens, migration utilities, and the branch that processes a negative balance after a reversal. Call it unobserved, never unreachable.
Behaviorally obsolete code still runs or remains reachable, but the business no longer needs its outcome. A tax rule for a jurisdiction the company left might be obsolete. A discontinued product calculation might not be, because old accounts still require corrections. Only a business owner, supported by data retention and contractual facts, can make this determination. A compiler cannot.
Use a state field rather than a Boolean flag:
- unreachable under model M
- unobserved in workload W during window T
- retained for named rare event E
- obsolete by decision D
- unknown, investigation required
That vocabulary prevents a quiet but common substitution. Engineers start with no runtime hits, talk about dead code in a meeting, and approve a deletion as though impossibility had been proved. The words changed while the evidence did not.
Static reachability finds impossibility, not disuse
Static reachability analysis is best at proving that ordinary control flow cannot reach a symbol from a known set of roots. It is weakest where a legacy system turns names and data into control flow. The result must always record its roots, resolution rules, and blind spots.
Start by enumerating every legitimate entry point, not merely the main executable. In a mainframe estate that can include transaction programs, batch steps in JCL, called modules, exits, database triggers, and utilities launched by operations. On an AS/400, command language programs and job descriptions may call RPG programs that no interactive path mentions. Desktop systems add COM entry points, report macros, and files opened by shell association. Web monoliths add scheduled scripts, queue consumers, framework hooks, and routes assembled from configuration.
Then build a call graph with edges labeled by confidence. A direct call resolved by the compiler deserves a stronger label than a string that happens to match a procedure name. Keep unresolved indirect calls visible instead of discarding them. Function pointers, reflection, dependency injection, late binding, generated SQL, stored procedure names, and plugin registries all create edges that a simple text search misses.
Linker garbage collection and compiler dead code elimination answer a narrower question. Their manuals describe removal of sections or instructions that cannot affect the produced program under a particular build. That is useful for binary size. It does not prove that a source routine is safe to remove across other build flags, separately loaded modules, scripts, or operational entry points. Treating an optimized binary as the complete application model is a category error.
A small, reviewable reachability artifact is more useful than a colorful graph with ten thousand nodes. Export one row per candidate with the symbol, source location, roots searched, incoming direct calls, possible indirect references, build variants, and analyzer version. If an edge depends on a string, configuration key, database row, or job name, store that evidence beside the row.
A candidate can earn a strong static finding only after you answer four questions: Which roots were included? Which languages and generated artifacts were parsed? How were indirect calls resolved? Which components sit outside the repository? If any answer is unknown, the finding remains provisional. That is not caution for its own sake. It is an accurate description of the model.
Runtime coverage proves execution, not safety
Runtime coverage can prove that code executed in a recorded workload, but zero hits cannot prove that code is unnecessary. Coverage is a witness for presence, not a proof of absence.
GNU gcov reports how often lines and branches ran in an instrumented program. Coverage.py makes a similar distinction for Python and can record branch destinations as well as statements. Both manuals frame results in terms of the program runs you supply. The qualification matters more than the percentage: a run exercises only the inputs, environment, dates, identities, and failures that occurred during that run.
Line coverage also loses control flow detail. A line containing a compound condition can execute while one operand never changes value. A switch statement can run while one case remains untouched. Branch coverage improves the evidence, but it still cannot tell you whether the observed output was correct or whether a side effect occurred with the right value. For deletion work, collect branch or edge coverage where the language permits it, and pair it with output comparison.
Instrument every execution surface you found during static analysis. Interactive traffic alone is rarely enough. Capture batch executables, queue workers, scheduled tasks, report generators, database routines, recovery commands, and administrative tools. Merge coverage only after retaining dimensions that explain it: program version, entry point, job name, tenant or business unit, role class, date, and workload source. A merged bitmap erases the fact that one line ran only in the annual close job.
Observation windows should follow the business calendar rather than a convenient number of days. Include daily, weekly, monthly, quarter end, year end, renewal, expiry, daylight saving, leap day, and regulatory reporting events that the system actually handles. If waiting for an event is impractical, replay a recorded workload or recreate the event in an isolated environment. Do not pretend that three busy weekdays represent a financial year.
Coverage instrumentation can change timing, memory layout, and failure behavior. Measure its overhead, especially around races, timeouts, and batch cutoffs. Where full instrumentation is unsafe, use sampled traces, call counters at stable boundaries, database audit records, or existing job logs. Weaker evidence is acceptable when it is named honestly and combined with other sources.
Time is part of the input
Rare calendar code is live code whose input includes a date, a cutoff, or an accumulated period state. Teams miss it because they treat time as background context instead of a first class input to the program.
Consider a year end allocation routine. The online application posts ordinary entries all year. On the final working day, a scheduler launches a batch job after the ledger closes. JCL passes a mode flag, a control table identifies accounts with deferred balances, and the program emits balancing entries into the next fiscal period. No web request calls the routine. No developer recognizes its abbreviated name. Eleven months of production coverage show zero hits.
A rewrite deletes the routine and replaces the surrounding ledger service. Ordinary parity tests pass because their fixtures use dates in March and June. At year end, totals remain internally balanced, so basic accounting checks also pass. The defect appears later when statements disagree with the contractual allocation rule. The lost business rule was not a dramatic branch. It was a scheduled combination of date, job control, table state, and output period.
To expose code like this, build an event inventory beside the call graph. Ask finance, operations, support, and compliance staff for named events, but do not rely on memory alone. Inspect scheduler definitions, JCL, cron tables, job history, run books, control tables, report calendars, file arrival patterns, and archive timestamps. Search for date comparisons, period numbers, holiday calendars, special processing flags, and constants that resemble old cutoff years.
Record the last observed execution and the next expected opportunity. A routine that last ran at the previous year end has a clear explanation. A routine with no hit for four years may still support a five year correction window. Conversely, a job that runs nightly can invoke a branch only when a control row exists. Frequency of the caller does not establish frequency of every rule inside it.
Clock substitution deserves its own test seam. Route application time through a controllable source where possible, and freeze database time or scheduler time consistently in the test environment. If one component reads a simulated date while another reads the host clock, the test can produce reassuring but impossible states.
Build an evidence ledger before deleting anything
An evidence ledger turns a vague dead code debate into a set of claims that another engineer can reproduce. It should live with the migration or cleanup work, receive review like code, and retain the raw references behind every conclusion.
One row per symbol or coherent feature is enough. Use fields such as candidate ID, source symbol, static status, static roots, unresolved edges, runtime hit count, observation window, workloads included, rare event tag, external invokers, business owner, disposition, and evidence locations. Do not convert unknown values to zero. Zero means measured and absent; unknown means you did not measure.
A coverage store can produce a useful candidate list with an ordinary query. Adjust names to your schema, but keep the grouping dimensions:
SELECT s.symbol_id, s.qualified_name,
COALESCE(SUM(c.hit_count), 0) AS hits,
MIN(c.observed_at) AS first_seen,
MAX(c.observed_at) AS last_seen,
COUNT(DISTINCT c.workload_id) AS workloads
FROM symbols s
LEFT JOIN coverage_events c ON c.symbol_id = s.symbol_id
WHERE s.release_id = :release_id
GROUP BY s.symbol_id, s.qualified_name
ORDER BY hits, s.qualified_name;
The output shape should be boring and inspectable: one symbol, its total hits, first and last observation, and the number of distinct workloads. Join it to a workload table that records whether each run represented online traffic, month end, year end, recovery, administration, or replay. A zero in a dataset containing only online traffic is not a deletion candidate for a batch symbol.
Add a compact decision table to the review:
| Static result | Runtime result | Business evidence | Action |
|---|---|---|---|
| Unreachable | No hits | No external entry | Isolate, then test deletion |
| Reachable | No hits | Rare event exists | Retain and add a targeted test |
| Reachable | Hits | Owner says obsolete | Confirm callers, then retire behavior |
| Unknown | No hits | Unknown | Investigate, do not delete |
The first row still requires a build and behavior check. Generated code, alternate builds, and packaging can invalidate an apparently closed graph. The third row also needs care: observed callers may depend on side effects even when the formal business feature has ended. Remove the call path and its data obligations as one change instead of leaving a broken partial path.
Exercise rare paths on purpose
A rare path should earn a targeted workload, not an assumption that production will eventually cover it. Construct tests from the event inventory and make the expected outputs explicit enough to catch a missing rule.
Recorded production traffic is useful because it preserves combinations that synthetic fixtures rarely anticipate. Scrub or tokenize sensitive fields according to the environment's requirements, retain ordering where state depends on it, and capture surrounding state such as control tables, clock values, and files. A request without its database state is often not a reproducible workload. For batch systems, archive job inputs, parameters, return codes, generated files, database changes, and operator messages.
Then add synthetic boundary cases. Use the day before, the event date, and the day after a cutoff. Test empty input, the smallest valid account, a negative or reversal case, an account created late in the period, and a rerun after partial completion. These are not random edge cases. They test whether the routine handles selection, calculation, persistence, and recovery separately.
Compare behavior at observable boundaries. HTTP responses matter, but so do database writes, file records, messages, exit codes, downstream calls, rounding, ordering, and timing windows that trigger retries. Normalize values that are intentionally nondeterministic, such as generated identifiers, while keeping business values exact. If the old system writes a fixed width record, compare field positions and padding as well as parsed values because a downstream consumer may depend on the bytes.
Do not chase one coverage percentage as the goal. A suite can reach a high line percentage while missing the one branch selected by fiscal period 13. Instead, map every candidate to the workload that should reach it or prove why no workload can. The useful unit is an explained symbol, not a colored line.
When a path cannot be exercised safely, create a characterization test below it. Call the calculation with captured inputs, run the stored procedure in a restored database, or invoke the batch module with its real parameter block. Document what remains untested at the integration boundary. A precise gap invites review; a rounded percentage conceals it.
Deletion should run as a controlled experiment
Delete suspected code in small, reversible units and make the system disprove your claim. The best deletion test removes the candidate, rebuilds every variant, replays the workload set, and compares all observable effects with the baseline.
Begin with isolation when dependencies are tangled. Put the candidate behind a single adapter, remove duplicate entry points, and add logging or counters at that boundary. This changes structure without changing behavior and gives you a cleaner measurement point. If the adapter sees no calls across the required event set, the later deletion has stronger evidence.
For each deletion, preserve four artifacts: the evidence ledger row, the exact source diff, baseline outputs, and outputs after removal. Run unit and integration tests, compile alternate targets, execute batch and administrative workloads, and replay recorded traffic. Check database mutations and emitted files, not merely process success. A successful return code can accompany a missing accounting entry.
Shadow comparison helps when a calculation can run without side effects. Execute old and replacement logic on the same captured input, suppress one side's writes, and compare normalized results. Do not shadow operations that charge, notify, reserve inventory, or mutate shared state unless the architecture provides a safe sink. Duplicate side effects create a new incident while trying to prevent one.
The popular recommendation to delete anything with zero hits after a fixed observation period is attractive because it produces a clean backlog and a simple metric. It is wrong for systems with calendar events, dormant contracts, manual recovery, or data driven dispatch. Set observation requirements per feature class instead: an online validation may need representative traffic across roles and tenants, while a close routine needs at least one faithful close workload.
Keep rollback practical. A source control revert is insufficient if deletion also changes data shape, removes a column, stops populating a file, or alters message contracts. Stage destructive schema changes after behavioral retirement, and retain a compatibility path until downstream evidence catches up. Reversible work lets the evidence improve without turning every uncertain finding into a committee stalemate.
A rewrite must preserve behavior before improving design
A rewrite should classify and test old behavior before deciding which old code deserves a new implementation. Translating every reachable routine preserves accidental structure, while deleting every suspicious routine loses rules. The target is behavioral parity for retained obligations, followed by deliberate architectural change.
Build the parity harness around production observations and named rare events. Feed the old and new systems the same ordered inputs and initial state. Compare responses, durable state, files, messages, and failure behavior. Where architecture changes the shape of an interface, compare a canonical business representation rather than forcing the new system to mimic old modules internally.
This is where the distinction between code parity and behavior parity pays for itself. A new Go service does not need the paragraph boundaries of a COBOL program or the global variables of a VB6 form. It does need the same allocation amounts, eligibility decisions, rounding rules, and recovery guarantees until an owner approves a changed rule. CodeHero uses recorded production traffic in a parity harness while modernizing the architecture, which is the right level of comparison for this problem.
Give deleted behavior a named negative assertion. If an obsolete report must disappear, test that no job schedules it and no file is emitted. If a retired product rule must stop applying, include an old eligible record and assert the newly approved treatment. Absence becomes testable when you define the boundary where an effect would have appeared.
Do not let the new design erase provenance. Link each implemented rule, intentionally changed rule, and omitted candidate back to evidence in the ledger. Reviewers should be able to ask why a branch exists in the target and find its workload, owner, and expected effect. They should also be able to ask why a source routine vanished and find more than a comment saying unused.
Set a deletion rule your reviewers can enforce
A deletion policy works only when it specifies evidence, authority, and a stopping condition. Write it so a reviewer can reject a change without debating whether the code feels old.
A defensible gate can require all of the following:
- Static analysis found no unexplained inbound path from the complete entry point inventory, or every remaining caller is included in the retirement.
- Runtime evidence covered the candidate's relevant workload classes and business calendar events, with raw records retained.
- External invokers, configuration references, scheduler entries, data driven dispatch, and operator procedures were checked.
- A named owner approved behavioral obsolescence, or technical evidence proved the path impossible without a business judgment.
- Removal passed builds, targeted tests, traffic replay, and comparison of durable side effects, with a rollback plan for dependent data and contracts.
The gate should allow an explicit unknown result. Some routines cannot be classified until an archive is restored or a close event is reconstructed. Marking them unknown is progress because it identifies the missing evidence. Deleting them to improve a percentage is not progress.
Measure the program of work with counts by evidence state: candidates investigated, paths proved unreachable, rare paths given tests, obsolete behaviors approved, unknowns remaining, and deletions that passed parity. Do not reward raw lines removed. A ten line year end rule can carry more obligation than ten thousand lines of abandoned screen code.
The first useful action is to take one alleged dead routine and write down the exact claim. If the claim says only nobody has seen it run, you have measured familiarity, not dead code. Add roots, workloads, calendar events, owners, and observable effects until another engineer can reproduce the conclusion. Only then does the delete key belong in the process.
FAQ
What is the difference between dead code and unused code?
Dead code has a supported claim that no required execution can reach or need it. Unused code often means only that a tool or observation window found no use, so external callers and rare events may still exist.
Can static analysis prove that code is dead?
It can prove unreachability within a defined model of roots and dispatch. The proof weakens if the system uses reflection, generated calls, configuration, schedulers, database triggers, or components outside the analyzed repository.
Does zero runtime coverage mean a function is safe to delete?
No. Zero hits mean the function did not run in the measured workloads and dates. You still need static reachability, rare event coverage, external caller checks, and a business decision before deletion.
How long should code coverage run before judging dead code?
Use the system's business calendar, not a fixed duration. The window or replay set must include relevant daily jobs, period closes, renewals, expiry events, recovery procedures, and any annual processing.
How do you find code that runs only at year end?
Inspect scheduler history, JCL or job definitions, run books, control tables, archive timestamps, and date conditions. Recreate the close with its real parameters and state, then record branch coverage and outputs.
Should generated or compiler-removed code count as dead source code?
Not automatically. A compiler proves what one build can omit from one produced program. Other build flags, loaded modules, scripts, and operational entry points can still require the source.
What should a dead code evidence ledger contain?
Record the symbol, entry points searched, unresolved edges, runtime workloads, observation dates, rare events, external references, owner, disposition, and raw evidence. Keep unknown distinct from zero.
How do you test a legacy code deletion?
Capture baseline outputs, remove a small candidate, rebuild every relevant variant, and replay targeted workloads. Compare durable state, files, messages, exit codes, and failure behavior, not only API responses.
Is a high code coverage percentage enough for a rewrite?
No. A high aggregate can miss a single fiscal branch or recovery path. Map retained behavior to named workloads and compare old and new observable effects at business boundaries.
Who should approve removal of an old business rule?
A named business owner should approve that the obligation is obsolete, with engineering evidence about callers and effects. Engineers can prove a path impossible, but they should not infer contract or policy changes from silence.