Interface-based dynamic dispatch defeats static analysis. These tools can type-check millions of lines of code in seconds, find null dereference bugs, identify unreachable branches, and flag unused imports across an entire project without running a single line of the program. But there is a ceiling. Knowing where it lies explains why static analysis tools, including the best ones, cannot be the only input for automated dead code removal.
We are not writing this to attack static analysis. We use it extensively in YAGNI. The point is that teams expecting these tools to reliably identify dead code can get into trouble when they act too aggressively on those results.
The Halting Problem Boundary
Static analysis has a fundamental theoretical limit, not one caused by engineering effort. Deciding whether arbitrary code is reachable is equivalent to the halting problem, which is provably undecidable for a sufficiently general model of computation. No static analysis tool, however sophisticated, can answer correctly for every possible program.
In practice, each static analysis tool chooses where to set its approximation boundary. It may over-approximate, marking more code reachable than truly is to avoid false negatives, or under-approximate, potentially marking live code as dead and creating false negatives that could cause running code to be removed incorrectly. Bug-finding tools generally over-approximate. Safe dead code removal should do the same.
Before deciding how to use a static analysis tool's output, you need to know which way its approximation tends to err.
Dynamic Dispatch and Interface Types
Interface-based method dispatch is the most common point where static analysis leaves the call graph incomplete. Consider this Go code:
type Processor interface {
Process(event Event) error
}
func RunPipeline(p Processor, events []Event) {
for _, e := range events {
p.Process(e)
}
}
Conservative analysis places every known concrete Processor implementation in the reachable set. That still is not perfect: code that is itself unreachable may construct some implementations. The analysis keeps those implementations live anyway, producing false positives that block clean removal.
Conservative static analysis adds all known concrete implementations of Processor to the reachable set. But that is not perfect either: some implementations might only be constructed in code that is itself unreachable. The analysis conservatively keeps them live anyway, generating false positives that prevent clean removal.
Callback Registrations and Event Systems
Event-driven architectures create another set of static analysis problems. A callback registered at startup is invoked by the event dispatcher when its matching event fires. The chain is: the event dispatcher invokes the function. Typically, that dispatcher is a third-party library calling through a registration table assembled at runtime. Static analysis cannot trace the library internals to follow that callback into user code.
To static analysis, the registered callback has no callers and appears dead. The registration code exists somewhere, but connecting it to the invocation depends heavily on the framework's patterns and the tool's framework-specific knowledge.
YAGNI explicitly handles common frameworks: Flask route registrations, Django URL configurations, FastAPI decorators, Express route handlers, and a set of message queue consumer patterns. For each recognized pattern, it adds the registered function to the entry point set instead of marking it dead. With unrecognized frameworks, those functions initially appear unreachable and need human confirmation before removal is proposed.
Serialization Bindings
Object serialization creates a third category of problems. When a data class is serialized to or deserialized from an external format, the serialization library generally uses reflection or annotation processing to enumerate its fields and methods. The static call graph shows no calls to those methods because the library invokes them, not user code.
Deleting a "dead" method called by the serialization library is a data corruption bug waiting to occur. Deserializing stored records that refer to the deleted field will fail or return incorrect output.
For removal, this is among static analysis's most dangerous false negatives. An unseen mechanism calls the code, so it looks dead; removing it can cause data loss or deserialization failure in production.
Configuration-Driven Behavior
Many services read handler registrations from configuration files or databases at startup. A handler name in a config file is a string. Static analysis cannot carry that string across the file system boundary into runtime, match it with available handler implementations in the codebase, and determine that the matching function is live.
The only correct approach is to recognize the loading pattern and conservatively mark every potentially-registered handler live, or have the human engineer place them on the explicit ignore list. Either way, static analysis by itself cannot resolve this category.
Where Runtime Signal Closes the Gap
Across these failure modes, the pattern is consistent: static analysis cannot see mechanisms that route calls beyond the visible code. Runtime instrumentation can. When a function executes in production, it is live, regardless of the call's reason or mechanism.
Runtime traces do not make static analysis obsolete. Static analysis is faster, broader, and requires no program execution. Runtime traces, however, resolve cases where static analysis falsely labels live functions as dead. If a function flagged as dead appears in production traces within the past 60 days, it is not dead. In every conflict, the traces take precedence over static analysis.
Combining both signals and requiring agreement before proposing high-confidence removals is conservative by design. The result is a smaller candidate set than static analysis alone provides. But these candidates are cases where we are genuinely confident the code is not running in production. Automated removal should wait until the analysis reaches that confidence level.
Try YAGNI on your codebase. Free plan, no credit card required.
Get Early Access