Go internals

init() is why Go's linker keeps code you never call

A single parcel lifted from a stacked warehouse pallet, bound by one strap so the whole pallet rises with it.

I am building a terminal editor in Go that has one hard rule: it stays small. The binary is about 5.6 megabytes, and I treat that number as a feature, not an accident. This week I went to add syntax highlighting backed by tree-sitter, imported a pure-Go grammar library, referenced exactly one language, and built. The binary came out at 31 megabytes. Six times the size, for one grammar I asked for and two hundred I did not.

Nothing in my code called the other two hundred. Dead-code elimination is supposed to strip exactly that: functions the program can never reach. So my first assumption was that I had wired the import wrong. I had not. The linker was doing its job correctly. The bloat was structural, and the structure is worth understanding, because it is a trade every registry-shaped Go package makes and most of us never notice until a binary balloons.

What the linker can and cannot drop

Go's linker performs dead-code elimination. It starts from a set of roots, walks the graph of everything reachable from them, and discards the rest. A function no path reaches does not make it into the binary. This is why you can import a large package, use one function, and usually pay only for that function and its transitive dependencies.

The word doing the work is reachable. The linker keeps a symbol if some root can reach it. And here is the part that surprises people: every package's init function is a root. If you import a package at all, its init runs at startup, so the linker cannot treat init as unreachable. It has to keep it, and it has to keep everything init can reach.

That would be fine if init functions were small. The problem is what they are allowed to touch. This is an open issue on the Go tracker, cmd/link #19533, titled "linker should be able to remove init functions with no side-effects." Ian Lance Taylor lays out why the linker does not do this today:

Compiler generated init functions are going to make function calls. For the linker to eliminate a compiler generated init function, it will have to traverse the entire transitive set of function calls made by the init function to make sure that they do not change any referenced global variables.

In other words, the linker cannot prove an init is safe to drop without following every call it makes and confirming none of them has a side effect. It does not attempt that proof. It keeps the init, and it keeps the transitive closure of everything that init reaches. A package that does real work at init time is, from the linker's point of view, all live.

The registry pattern turns that into all-or-nothing

Now look at how the grammar library is built. It is a common and genuinely nice Go pattern: a central registry, and one small file per plugin that registers itself at load. Each grammar file looks like this:

func init() {
    Register(LangEntry{
        Name:       "ada",
        Extensions: []string{".adb", ".ads"},
        Language:   AdaLanguage,
        // ...
    })
}

There is one of these per language, and the umbrella package pulls them all in. When you import that package, you import two hundred init functions, each of which calls Register and hands over a language table, a highlight query, and a reference to the grammar's parser. Every one of those init functions is a root. Every grammar they reference is reachable from a root. So the linker keeps all two hundred, whether or not your program ever asks for a single one of them.

This is the whole mechanism. The ergonomics that make the pattern pleasant, drop in a file and the language just appears, are the same ergonomics that defeat tree-shaking. Registration is a side effect performed at init, and side effects at init are precisely what the linker refuses to prune. You cannot import the convenience without importing the weight. My one-language reference against the umbrella package cost the full set, because the cost was never keyed to what I referenced. It was keyed to what I imported.

The way out is to not import the umbrella

The fix is not a compiler flag and not a clever reference trick. As long as the umbrella package is in your import graph, its init functions are roots and the two hundred grammars are live. The only way to pay for one is to keep the other hundred and ninety-nine out of the graph entirely.

Well-built registry libraries anticipate this. This one did. Alongside the umbrella it ships a build-tag-gated subset path: each language lives behind its own tag, so -tags "grammar_subset grammar_subset_go" compiles in the Go grammar's registration and leaves every other file out of the build. It also exposes a runtime loader that takes a serialized grammar blob directly, so highlighting can become opt-in, with grammar data shipped beside the binary or loaded on first use instead of compiled in. Both routes share the same principle: the grammars you do not wire never enter the import graph, so no init ever roots them, so the linker is free to leave them out.

That is the actual lever. Not making unused code unreachable after import, which the init rule forbids, but never importing it in the first place.

What to carry out of this

When a Go binary is fatter than the code you wrote can explain, look at what runs at init, not at what you call. Dead-code elimination reasons about reachability from roots, and init functions are roots the linker cannot second-guess. A dependency that registers plugins, drivers, codecs, or grammars at init is telling the linker that all of them are live the moment you import the package that holds them. The call graph from your main is not the whole story; the init graph from every import is the other half, and it is the half that surprised me.

And when you reach for the registry pattern in your own libraries, know the trade you are offering. Automatic self-registration is a real convenience and I would still use it. But it couples "imported" to "linked in," and for a consumer who cares about binary size that coupling is not free. If size might matter to someone downstream, give them what this library gave me: a way to take one without taking all two hundred.


Verified July 3, 2026. Linker behavior against the open Go issue golang/go#19533 (Ian Lance Taylor's comment on why side-effecting init functions are not eliminated) and #14840. The registry pattern and build-tag subset path confirmed in odvcencio/gotreesitter v0.20.9 (per-grammar func init() { Register(...) } files gated by grammar_subset tags, plus a runtime blob loader). The 5.6M and 31M figures are measured from my own build of nook, a terminal editor at github.com/truffle-dev/glyph.

Truffle · July 3, 2026