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:

  1. A direct call — handled by the flood fill.
  2. A call through an interface — handled by signature matching, which is deliberately over-conservative.
  3. 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:

go
// 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            bool

The roots are main.main (or the entry symbol), runtime.unreachableMethod, the dynamic exports, and the package init tasks:

go
// 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:

go
// 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:

go
// 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:

go
// 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.Pkg

Note 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”:

go
// 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:

go
// 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 values

mcount 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:

go
// 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:

  • reflectSeen and 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:

go
// 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:

go
// 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:

go
// 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:

go
// 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:

go
// 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:

go
// 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:

go
// 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:

go
// 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:

go
// src/cmd/link/internal/ld/main.go:64
	flag.Var(&flagW, "w", "disable DWARF generation")
go
// 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:

go
// 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:

go
// 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:

FlagRemovesKept
-wDWARF debug info: types, variables, line tables, inlined framesThe symbol table — you can still nm the binary
-sThe symbol table: function and object namesNothing 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 MethodByName with 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. -s removes what pprof, objdump and 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:

go
// 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 — where deadcode(ctxt) sits in the link pipeline, and the -w and -s flags.
  • src/cmd/link/internal/ld/dwarf.go — dwarfEnabled, the -w short-circuit.
  • src/cmd/link/internal/ld/lib.go — hostlink, where -s and -w are forwarded to the host linker in external-link mode.
  • src/cmd/link/internal/ld/data.go — the unreachable-symbol redirect to runtime.unreachableMethod, and the “unreachable sym in relocation” check.
  • src/cmd/compile/internal/reflectdata/reflect.go — dextratype, dmethodptrOff, and the Mcount/Xcount/Moff layout of the UncommonType method table.
  • src/cmd/compile/internal/walk/expr.go — usemethod, where the R_USENAMEDMETHOD relocation and the REFLECTMETHOD attribute are set.
  • src/cmd/compile/internal/staticdata/data.go — where reflect.Value.Method and reflect.Value.MethodByName are flagged.
  • src/runtime/iface.go — unreachableMethod, the canary the linker redirects pruned method calls to.

GoInternalsLinkerCompilerPerformance