Go's Linker Deletes Code You Never Call: Method Pruning and Reachability
The Go linker flood-fills the symbol graph from main.main and drops everything it cannot reach, then prunes the method tables of surviving types unless reflection could reach them.
TL;DR: Go binaries are not “the standard library plus your code”. The linker floods the symbol graph starting at
main.main, keeps only what it reaches, and then throws away the method tables of surviving types unless an interface or a reflective call could reach them. Everything it drops is gone from the binary — code, DWARF, and (with-s) the symbol table.
Every Go binary you ship contains a net/http server, a crypto/tls
stack, a JSON encoder, and about nine hundred other packages’ worth of
code. Almost none of it is running. Most of it is not even in the
binary.
The Go linker does a reachability analysis over the symbol graph and deletes what it cannot prove is live. The interesting part is not that it deletes unreachable functions — that is easy. It is what it does with methods, because a method can be reached three ways and only one is visible in the object file.
This is a walk through how it works, based on reading the Go 1.28 development tree.
The rule the linker follows
The whole design is stated in one comment, in
src/cmd/link/internal/ld/deadcode.go, and it comes down to three ways a
method of a reachable type can be invoked:
- A direct call — handled by the flood fill.
- A call through an interface — handled by signature matching, which is deliberately over-conservative.
- A call through reflection — handled by giving up.
The flood fill
The pass itself is a worklist algorithm over the symbol graph. The
deadcodePass struct holds the state:
// src/cmd/link/internal/ld/deadcode.go:22
type deadcodePass struct {
ctxt *Link
ldr *loader.Loader
wq heap // work queue, using min-heap for better locality
ifaceMethod map[methodsig]bool // methods called from reached interface call sites
genericIfaceMethod map[string]bool // names of methods called from reached generic interface call sites
markableMethods []methodref // methods of reached types
reflectSeen bool // whether we have seen a reflect method call
dynlink boolThe roots are main.main (or the entry symbol),
runtime.unreachableMethod, the dynamic exports, and the package init
tasks:
// src/cmd/link/internal/ld/deadcode.go:82
// runtime.unreachableMethod is a function that will throw if called.
// We redirect unreachable methods to it.
names = append(names, "runtime.unreachableMethod")Marking is simple — a bitmap on the loader, and a push onto a min-heap so the traversal visits symbols in a cache-friendly order:
// src/cmd/link/internal/ld/deadcode.go:359
func (d *deadcodePass) mark(symIdx, parent loader.Sym) {
if symIdx != 0 && !d.ldr.AttrReachable(symIdx) {
d.wq.push(symIdx)
d.ldr.SetAttrReachable(symIdx, true)
if buildcfg.Experiment.FieldTrack && d.ldr.Reachparent[symIdx] == 0 {
d.ldr.Reachparent[symIdx] = parent
}
if *flagDumpDep {Reachparent is a nice touch: with the FieldTrack experiment on, the
linker records which symbol made each symbol live.
Where the method table lives
A method is not a symbol you can point at directly from the type
descriptor. The rtype holds an array of reflect.method structs, and
each entry has three relocations (mtyp, ifn, tfn). The compiler marks
those relocations with a special type, R_METHODOFF, so the linker can find
them:
// src/cmd/compile/internal/reflectdata/reflect.go:384
func dmethodptrOff(c rttype.Cursor, x *obj.LSym) {
c.WriteInt32(0)
c.Reloc(obj.Reloc{Type: objabi.R_METHODOFF, Sym: x})
}And the table itself is written into the UncommonType section that
follows the common type data:
// src/cmd/compile/internal/reflectdata/reflect.go:345
c.Field("Mcount").WriteUint16(uint16(mcount))
c.Field("Xcount").WriteUint16(uint16(xcount))
c.Field("Moff").WriteUint32(uint32(dataAdd))
// Note: there is an unused uint32 field here.
// Write the backing array for the []method field.
array := rttype.NewArrayCursor(lsym, off+int64(dataAdd), rttype.Method, mcount)
for i, a := range m {
exported := types.IsExported(a.name.Name)
var pkg *types.Pkg
if !exported && a.name.Pkg != typePkg(t) {
pkg = a.name.PkgNote Mcount (all methods) and Xcount (exported methods). Different
numbers, and that distinction is what makes pruning possible: the exported
prefix is the part reflection can reach.
The linker sees these relocations while flooding but does not follow them immediately — that would keep every method of every reachable type alive. Instead it records them as “markable”:
// src/cmd/link/internal/ld/deadcode.go:184
case objabi.R_METHODOFF:
if i+2 >= relocs.Count() {
panic("expect three consecutive R_METHODOFF relocs")
}
if usedInIface {
methods = append(methods, methodref{src: symIdx, r: i})
// The method descriptor is itself a type descriptor, and
// it can be used to reach other types, e.g. by using
// reflect.Type.Method(i).Type.In(j). We need to traverse
// its child types with UsedInIface set. (See also the
// comment below.)
rs := r.Sym()
if !d.ldr.AttrUsedInIface(rs) {
d.ldr.SetAttrUsedInIface(rs, true)Two things are happening. The method is only a candidate if its type is
“used in interface” — converted to an interface somewhere, so an interface
call might dispatch to it. And the method’s own type descriptor is marked
reachable, because reflect.Type.Method(i).Type.In(j) reaches types
through it.
At the end of the flood, the candidate methods are decoded. The linker reads the method’s name and type straight out of the type descriptor:
// src/cmd/link/internal/ld/deadcode.go:543
func (d *deadcodePass) decodetypeMethods(ldr *loader.Loader, arch *sys.Arch, symIdx loader.Sym, relocs *loader.Relocs) []methodsig {
p := ldr.Data(symIdx)
if !decodetypeHasUncommon(arch, p) {
panic(fmt.Sprintf("no methods on %q", ldr.SymName(symIdx)))
}
off := abi.RTypeSize(decodetypeKind(arch, p), arch.PtrSize)
mcount := int(decodeInuxi(arch, p[off+4:], 2))
moff := int(decodeInuxi(arch, p[off+4+2+2:], 4))
off += moff // offset to array of reflect.method valuesmcount and moff are read straight out of the UncommonType header the
compiler wrote. This is the linker and the compiler agreeing on a binary
layout — which is why internal/abi exists, and why the compiler has a
separate rttype package mirroring it.
The pruning loop
Pruning is not a single pass, because pruning a method can reveal new interfaces, which keep more methods alive. The loop repeats until the work queue drains:
// src/cmd/link/internal/ld/deadcode.go:455
for {
// Mark all methods that could satisfy a discovered
// interface as reachable. We recheck old marked interfaces
// as new types (with new methods) may have been discovered
// in the last pass.
rem := d.markableMethods[:0]
for _, m := range d.markableMethods {
if (d.reflectSeen && (m.isExported() || d.dynlink)) || d.ifaceMethod[m.m] || d.genericIfaceMethod[m.m.name] {
d.markMethod(m)
} else {
rem = append(rem, m)
}That single if is the whole method-pruning policy. A candidate method
survives if any of these hold:
reflectSeenand the method is exported — reflection could call it.d.ifaceMethod[m.m]— some reachable interface has a method with the same name and the same function type.d.genericIfaceMethod[m.m.name]— some reachable generic interface call site named this method.
The matching is by signature, not by implementation. If any reachable
interface declares a Read([]byte) (int, error), every interface-used type
with a matching method keeps it — even if it could never be assigned to
that interface. The comment calls this “extremely conservative, but easy
and correct”, and it is: the analysis is linear and cannot drop a live
method.
The reflection escape hatch
The third route — reflection — is where the linker gives up, and when it gives up is the single biggest lever on binary size. The compiler sets a flag on the function that does the reflective lookup. If the method name is a compile-time constant, it can emit a precise relocation:
// src/cmd/compile/internal/walk/expr.go:1140
if ir.IsConst(targetName, constant.String) {
name := constant.StringVal(targetName.Val())
w.curfunc.LSym.AddRel(base.Ctxt, obj.Reloc{
Type: objabi.R_USENAMEDMETHOD,
Sym: staticdata.StringSymNoCommon(name),
})
} else {
w.curfunc.LSym.Set(obj.AttrReflectMethod, true)
}If the name is not constant, the function gets the REFLECTMETHOD
attribute and the linker will keep every exported method of every
reachable type. The linker sees this through the loader:
// src/cmd/link/internal/loader/loader.go:1252
// Returns whether the i-th symbol has ReflectMethod attribute set.
func (l *Loader) IsReflectMethod(i Sym) bool {
return l.SymAttr(i)&goobj.SymFlagReflectMethod != 0
}And the flood pass flips a single global boolean the moment it sees one:
// src/cmd/link/internal/ld/deadcode.go:141
// Methods may be called via reflection. Give up on static analysis,
// and mark all exported methods of all reachable types as reachable.
d.reflectSeen = d.reflectSeen || d.ldr.IsReflectMethod(symIdx)There is a second, sneakier way to get flagged. reflect.Value.Method
and reflect.Value.MethodByName are function values, and the compiler
flags any function whose address is taken for that purpose:
// src/cmd/compile/internal/staticdata/data.go:291
// A function value of reflect.Value.Method or MethodByName may be
// called with a method name the compiler cannot see; see usemethod.
if s.Pkg.Path == "reflect" && (s.Name == "Value.Method" || s.Name == "Value.MethodByName") {
sf.Set(obj.AttrReflectMethod, true)
}So even m := reflect.Value.Method; m(v, 3) — where v is not even an
interface — turns on “keep every exported method” for the whole binary. A
documented cost, and reflect says so out loud:
// src/reflect/value.go:1895
// Calling this method will force the linker to retain all exported methods in all packages.
// This may make the executable binary larger but will not affect execution time.
func (v Value) Method(i int) Value {“Will not affect execution time” is the important half. Pruning is a link-time decision that never adds a runtime branch: a binary with all its methods retained runs exactly as fast as one with half of them removed. It is just bigger.
What happens to the pruned methods
Pruned methods are not merely unreferenced — they are actively poisoned. A relocation to a symbol the linker considers unreachable is redirected to a runtime stub that throws:
// src/cmd/link/internal/ld/data.go:353
case objabi.R_ADDR, objabi.R_PEIMAGEOFF:
if weak && !ldr.AttrReachable(rs) {
// Redirect it to runtime.unreachableMethod, which will throw if called.
rs = syms.unreachableMethod
}And that stub is deliberately loud:
// src/runtime/iface.go:726
// unreachable to a reference to this function, so it will throw if
// ever called.
func unreachableMethod() {
throw("unreachable method called. linker bug?")
}“linker bug?” — because if this fires, the linker was wrong. A canary, not a feature.
The linker also checks itself. Any non-weak relocation to a symbol it believes is unreachable is a hard error:
// src/cmd/link/internal/ld/data.go:260
if rs != 0 && rst != sym.STLSBSS && !weak && rt != objabi.R_METHODOFF && !ldr.AttrReachable(rs) {
st.err.Errorf(s, "unreachable sym in relocation: %s", ldr.SymName(rs))
}R_METHODOFF is excluded precisely because those relocations are
supposed to point at not-yet-decided methods.
The size effect
A Go binary’s size is dominated by three things: reachable code, type
metadata, and debug information. Dead code elimination takes the first.
Method pruning takes a slice of the second — a bigger slice than you would
think, because a method’s metadata is not just the function body. Each
method carries a reflect.method entry, a method wrapper (the
ABI0→ABIInternal thunk), a type descriptor for its signature, and its name
string. Pruning one method can take five or six objects with it.
The most dramatic size effect is not method pruning at all — it is
reflection. A program that calls reflect.Value.MethodByName(name) with a
runtime string keeps every exported method of every reachable type in the
program. That is the difference between a 4 MB binary and an 11 MB one,
and it comes down to a single boolean.
-w and -s: the other half of the size story
Reachability pruning is about code. Debug information is a separate lever, and a much bigger one for most binaries. The linker has two flags for it:
// src/cmd/link/internal/ld/main.go:64
flag.Var(&flagW, "w", "disable DWARF generation")// src/cmd/link/internal/ld/main.go:100
FlagS = flag.Bool("s", false, "disable symbol table")-s implies -w, which the linker resolves at startup:
// src/cmd/link/internal/ld/main.go:270
switch flagW {
case ternaryFlagFalse:
*FlagW = false
case ternaryFlagTrue:
*FlagW = true
case ternaryFlagUnset:
*FlagW = *FlagS // -s implies -w if not explicitly set
if ctxt.IsDarwin() && ctxt.BuildMode == BuildModeCShared && !*FlagSplitDWARF {In external-linking mode the same flags are handed down to the host linker
as -s or -Wl,-S.
-w short-circuits DWARF generation:
// src/cmd/link/internal/ld/dwarf.go:1841
func dwarfEnabled(ctxt *Link) bool {
if *FlagW { // disable dwarf
return false
}
if ctxt.HeadType == objabi.Hplan9 || ctxt.HeadType == objabi.Hjs || ctxt.HeadType == objabi.Hwasip1 {
return false
}The two flags do different things:
| Flag | Removes | Kept |
|---|---|---|
-w | DWARF debug info: types, variables, line tables, inlined frames | The symbol table — you can still nm the binary |
-s | The symbol table: function and object names | Nothing extra; also implies -w |
-w is nearly free in production: you lose the debugger and the profiler’s
symbolication but keep go tool nm. -s is more aggressive — the symbol
table is what objdump, pprof and most crash symbolizers read, and
without it your stack traces become raw addresses. The usual production
recipe is -ldflags="-s -w", or just -w if you want a profile-able
binary.
Trade-offs
What pruning buys: smaller binaries, faster cold starts, less page
cache pressure. On a typical service binary the difference between
-ldflags="-s -w" and no flags is easily 25-30% of file size.
What it costs:
- Reflection is a size cliff. One
MethodByNamewith a runtime string keeps every exported method in the program. This is the single most common cause of “why is my Go binary 10 MB”. - The interface matching is conservative. Any reachable interface with a matching signature keeps that method on every interface-used type, whether or not that type could ever satisfy the interface.
- Pruning happens at link time. You pay for the analysis on every link, and the result depends on the whole program, so it cannot be cached per-package.
- Stripping symbols breaks tooling.
-sremoves whatpprof,objdumpand most error reporters need. You trade observability for bytes.
Caveats
-s and -w are not the same. -w only removes DWARF. The symbol
table survives, so go tool nm still works. -s implies -w, and you can
negate that with -w=0.
Pruning does not change runtime behaviour, only size. If a reachable
method was removed, the linker would have redirected the call to
unreachableMethod and thrown “unreachable method called. linker bug?” at
runtime. Seeing that panic means the linker is wrong — most likely an
unsupported //go:linkname, a plugin, or a -linkshared build.
Shared libraries and plugins disable most of this. A -buildmode=shared
library marks every symbol it defines as reachable, and dynamic linking
forces reflectSeen = true:
// src/cmd/link/internal/ld/deadcode.go:449
if ctxt.DynlinkingGo() {
// Exported methods may satisfy interfaces we don't know
// about yet when dynamically linking.
d.reflectSeen = true
}The linker catches its own mistakes. Any strong relocation to an unreachable symbol is a link-time error, not a silent corruption.
The reflection escape hatch only keeps exported methods. Unexported
methods are never kept by it, because reflect cannot reach them by name
either.
Wrapping up
The one thing to remember: the Go linker deletes code you never call.
It floods the symbol graph from main.main, follows relocations, and
keeps only what it reaches. Everything else never makes it into the
binary.
The part people get wrong: the linker cannot see reflection. One
reflect.Value.MethodByName with a non-constant argument — or even
taking the address of reflect.Value.Method — sets a flag that keeps
every exported method of every reachable type in the binary. If your
Go binary is inexplicably huge, look there first.
What to go do about it: build with -ldflags="-w" (or -s -w if
you do not need a symbolisable binary) and measure. Then run
go tool nm -size on the result and look for functions from packages
you never imported by hand — net/http, crypto/x509, regexp — and
ask whether something in your dependency graph is calling MethodByName.
Further reading
src/cmd/link/internal/ld/deadcode.go— the whole reachability and method-pruning pass:deadcode,deadcodePass,flood,mark,markMethod,decodetypeMethods.src/cmd/link/internal/ld/heap.go— the min-heap work queue used to drive the flood fill.src/cmd/link/internal/loader/loader.go—AttrReachable,SetAttrReachable,IsReflectMethod,InitReachable.src/cmd/link/internal/ld/main.go— wheredeadcode(ctxt)sits in the link pipeline, and the-wand-sflags.src/cmd/link/internal/ld/dwarf.go—dwarfEnabled, the-wshort-circuit.src/cmd/link/internal/ld/lib.go—hostlink, where-sand-ware forwarded to the host linker in external-link mode.src/cmd/link/internal/ld/data.go— the unreachable-symbol redirect toruntime.unreachableMethod, and the “unreachable sym in relocation” check.src/cmd/compile/internal/reflectdata/reflect.go—dextratype,dmethodptrOff, and theMcount/Xcount/Mofflayout of theUncommonTypemethod table.src/cmd/compile/internal/walk/expr.go—usemethod, where theR_USENAMEDMETHODrelocation and theREFLECTMETHODattribute are set.src/cmd/compile/internal/staticdata/data.go— wherereflect.Value.Methodandreflect.Value.MethodByNameare flagged.src/runtime/iface.go—unreachableMethod, the canary the linker redirects pruned method calls to.