Why does a reachable reflect.Value.MethodByName stop Go's linker from pruning methods?
answer
- the linker has to prove a negative
- the name only exists at run time
- conservative means keep everything exported
- it is program-wide, not per type
- size is the symptom, surface is the cost
basics
~20 sThe name is a run-time string, so the linker cannot prove which method it selects. Once MethodByName is reachable it conservatively keeps exported methods, so the binary grows and every exported method stays callable from data.
solid answer
~50 sGo's linker drops code it can prove nothing reaches. Direct calls are easy, and calls through an interface are resolved by matching the method names invoked through interfaces against the types converted to interface values — anything unmatched is dropped. `reflect.Value.MethodByName` breaks that proof: the name is computed at run time, so any exported method could be the target. The toolchain therefore records that the program makes reflective method calls, and the linker keeps the exported methods of reflect-reachable types instead of pruning them. Two consequences follow. The binary gets bigger and stops shrinking when you delete callers, because nothing is unreachable any more. And, more importantly, every exported method on those types is now invocable from a string — the exported set has become the dispatch surface. The reliable fix is not a build flag but removing the reachable `MethodByName`: a `map[string]func(...)` registry references each handler at compile time, so pruning works again.
code
go · 13 lines// method expressions are ordinary compile-time references
var commands = map[string]func(*Admin, string) error{
"drain": (*Admin).Drain,
"cordon": (*Admin).Cordon,
}
func dispatch(a *Admin, name, reason string) error {
cmd, ok := commands[name]
if !ok {
return fmt.Errorf("unknown command %q", name)
}
return cmd(a, reason)
}go deeper
Know that Go links statically and that the linker removes code it can prove unreachable, and that resolving a method from a run-time string defeats that proof.
Explain how interface calls are resolved during dead-code elimination and why a name computed at run time forces the conservative path across the whole program.
Diagnose it from symptoms — a binary that grew and no longer shrinks when callers are deleted — measure it with go tool nm, and know that only removing the reachable lookup fixes it.
Weigh the trade for the organisation: handlers addable without touching the dispatcher, against a binary floor, lost rename safety and an exported method set that has quietly become a security boundary.
## What the linker is trying to do Go links statically, so every byte of reachable code ships in the binary. `cmd/link` therefore runs a dead-code pass: starting from the entry point it walks the call graph and keeps only what can be reached. Direct calls are trivial to follow. Interface calls need a trick: the linker records which *method names* are called through interfaces anywhere in the program, and which concrete types are ever converted to an interface value. A method survives when a type that reaches an interface has a method whose name is called through some interface. Everything else — including exported methods nobody ever calls — is dropped. This is why a Go binary that imports a large package does not necessarily carry all of it. ## What reflection does to that reasoning `reflect.Value.MethodByName(name)` selects a method from a string that exists only at run time. The linker cannot evaluate that string; the name may be read from a config file, an HTTP request or an operator's terminal. So the proof "nothing calls this method" is no longer available for *any* exported method of a type that reflection could get hold of. The toolchain handles this conservatively. The compiler records that a package performs reflective method lookup, and once such a call is reachable from the entry point the linker stops pruning methods of reflect-reachable types and keeps their exported method sets. The same applies to `reflect.Type.MethodByName` and to the index-based `Value.Method`. Note the scope: this is a **program-wide** decision, not a per-type one. You cannot confine it by only ever dispatching onto one small handler struct. If `MethodByName` is reachable at all — including from a dependency you did not write — the conservative behaviour applies across the program. ## The symptoms - The binary jumps in size when the dispatcher lands, often noticeably in a service that pulls in large dependencies. - Deleting an unused exported method no longer shrinks anything, which is confusing in review: the diff removes code and the artefact does not change. - Size stays flat as the codebase grows dead weight, because nothing is dead any more. `go tool nm -size` on the two binaries, before and after, shows the retained symbols concretely; comparing symbol lists is more convincing in a review than the total size alone. ## The consequence that matters more than size Binary size is the visible symptom; the surface is the real one. Once dispatch resolves names from data, **every exported method on a dispatchable type is reachable from an operator-supplied string**. That includes methods added next month by someone who has never read the dispatcher and thinks they are writing an ordinary helper. Reflection cannot see unexported methods, so the export marker — previously a documentation decision — silently becomes a security boundary. ## What actually fixes it **Remove the reachable `MethodByName`.** An explicit registry does the same job with compile-time references: var commands = map[string]func(*Admin, string) error{ "drain": (*Admin).Drain, "cordon": (*Admin).Cordon, } Those method expressions are ordinary references, so pruning works, renames are caught by the compiler, and the set of dispatchable commands is exactly the set you can read on one screen. What does **not** work: - `-ldflags="-s -w"` strips the symbol table and DWARF. It shrinks the file but removes no code and changes nothing about which methods are callable. - Hiding the call behind a run-time condition. Reachability is static; an `if` that is always false still makes the call reachable. - Unexporting the methods you do not want dispatched. Reflection could never reach them anyway, and the conservative linker behaviour is triggered by the presence of the reflective call, not by which methods exist. ## How to talk about it in review The honest framing is a trade, not a bug: name-based dispatch buys you handlers that can be added without touching the dispatcher, and costs you dead-code elimination, rename safety and a bounded surface. On a small internal tool the cost is negligible. On a service where the binary ships to many machines, or where the exported method set is large and grows by contribution from several teams, it is the deciding argument.
- How does the linker decide a method is unreachable in a program that never touches reflection?It walks the call graph from the entry point. Direct calls are followed outright; for interface calls it records which method names are invoked through interfaces and which concrete types are converted to interface values, and keeps a method only when both sides line up. Exported methods that no such call could reach are dropped.
- A colleague proposes -ldflags='-s -w' to recover the lost binary size. What do you say?Those flags strip the symbol table and DWARF debug information. The file gets smaller and debugging gets worse, but no code is removed and every exported method is still dispatchable by name. It treats the symptom with the wrong instrument; the retained methods come back the moment you look at the code, not the file size.
- Can you limit the effect by dispatching only onto one small handler type?No. The conservative behaviour is triggered by a reachable reflective method lookup anywhere in the program, including inside a dependency, and it applies to reflect-reachable types generally rather than to the one you dispatch on. You can bound your own *intent* that way, but not the linker's pruning.
- How would you demonstrate the cost concretely in a pull request?Build the binary with and without the dispatcher and compare `go tool nm -size` output, not just the total file size. The symbol list shows which methods are retained, which turns an abstract argument about dead-code elimination into a diff a reviewer can read.
saying these in an interview costs you the question
- Thinks stripping symbols with -s -w removes the retained methods
- Believes the effect is limited to the type being dispatched on
- Says unexporting methods restores the linker's pruning
- Assumes the linker can evaluate the name string at build time
- Treats binary growth as the whole cost and ignores the surface