What the Go Linker Does

Raveesh Agarwal

·

·

8–11 minutes
Archives main.a, fmt.a and runtime.a flow into cmd/link, which runs once at build time and produces app; the string v1.4.2 and the -X flag are visible inside it.

Every go build ends by running a program most of us never call by name: cmd/link, the Go linker. I had used it for years, mostly through an -ldflags line I’d copied from someone’s Makefile, before I asked what it was actually doing. This post is what I found when I did.

Why is there a second step at all?

Go compiles one package at a time. When the compiler works on main, it sees a call to fmt.Println, but fmt is compiled separately, so the compiler can’t know where that function will end up in memory. It writes the call with a blank where the address should go, and moves on.

So the output of compilation is a set of object archives (.a files), one per package, each with machine code, a list of the symbols it defines, and blanks that point at other packages. Something has to take all of those archives together and fill in the blanks. That’s the linker.

Two phases of go build. Packages main, fmt and runtime each go through cmd/compile into an archive with unresolved references; cmd/link resolves symbols, drops dead code, lays out sections, patches addresses, adds runtime tables and writes one executable. All of this happens at build time, before ./app runs.

You can see both steps in the build log:

go build -x . 2>&1 | grep -E 'compile|link'Code language: JavaScript (javascript)

It can be run on its own as go tool link, though in practice you talk to it through -ldflags, which go build passes along unchanged.

When does linking happen?

This sounds like a trivial question, but most of the confusion around linker flags comes from getting it slightly wrong. The linker runs once, on the machine that builds the program, and its output is a file. Everything it decided is now part of that file. When the program starts later, on some other machine, nothing about linking happens again.

It helps to sort things by when they’re decided:

Decided when the program is linkedDecided when the program runs
Which functions exist in the binaryWhich of them get called
Every address and every call targetWhich goroutine runs where
Strings set with -XFlags, environment variables, config files
Static or dynamic, executable or shared libraryWhich files, sockets and secrets it opens

The left column is fixed once the build finishes. If you want to change something there, you build again. If you want to know what’s there, you read the file. We’ll come back to that second point.

What does it do with the archives?

It connects the calls

For each blank the compiler left, the linker looks up the symbol it refers to, finds the package that defines it, and records the connection. The call from main to fmt.Println now has somewhere to go.

It leaves out what nothing uses

Does every function in every imported package end up in your binary? It doesn’t. The linker starts from main.main and every init function and follows calls outward. Whatever it never reaches is left out.

Reachability from main.main and init(). fmt.Println, config.Load, json.Unmarshal, fmt.Fprintln, os.ReadFile and reflect.Value are reachable and kept; json.Indent, http.Serve and config.Legacy are never reached and are dropped.

That’s why importing a large package usually costs less than you’d guess. You pay for the functions you reach, plus whatever they call. Reflection weakens this: if code can call a method by a name that’s only known at runtime, the linker can’t prove that method is unused, so it keeps more than it otherwise would.

It decides where everything lives

What remains gets grouped into sections: machine code in .text, read-only data such as string bytes in .rodata, initialised variables in .data, zeroed variables in .bss. Once everything has a final address, the linker goes back over every reference and writes the real address in. This last pass is called relocation.

Then it writes the file in whatever format the operating system expects: ELF on Linux, Mach-O on macOS, PE on Windows, Wasm for js/wasm and wasip1. On macOS arm64 it also signs the binary with an ad-hoc signature, since the OS won’t run unsigned code there.

Anatomy of a Go binary: .text, .rodata, .data, .bss, pclntab, type information, buildinfo, symbol table and DWARF. -X values appear in .rodata as plain strings and again in buildinfo inside the recorded -ldflags. The runtime needs pclntab and type information. -s removes the symbol table and -w removes DWARF, nothing else.

It writes down what the runtime will need to know

Here Go differs from a C toolchain. A Go program carries a runtime that needs to understand the program it’s running, and the linker is where that understanding gets written down. It builds pclntab, which maps machine-code addresses back to function names, files and line numbers, and is what makes stack traces readable. It records where the pointers are in each stack frame and each type, so the garbage collector can find them. And it emits type descriptors and interface tables (itabs), which interface calls, type switches and reflect rely on.

None of this can be stripped. The program doesn’t run without it.

It records a few things about the build

The linker also adds DWARF debug information for Delve and other debuggers (compressed, and removable with -w), and a .go.buildinfo section with the Go version, the version of every module, and the settings the build used. go version -m and debug.ReadBuildInfo() read that section. It applies -X assignments, which we’ll look at next. And it resolves go:linkname directives, which let one package reach a private symbol in another; since Go 1.23 it refuses new links of that kind into the standard library unless the standard library opts in.

What does -X actually change?

Of everything the linker does, -X is the part most people touch directly. It sets the initial value of a string variable. The usual example is a version number:

package main

var (
	version = "dev"
	commit  string
)Code language: JavaScript (javascript)
go build -ldflags "-X main.version=v1.4.2 -X main.commit=$(git rev-parse --short HEAD)" .Code language: JavaScript (javascript)

It’s worth being precise about what this does, because two of its failure modes are silent. The target has to be a package-level var of type string, named by its full import path (main.version for the main package, example.com/app/internal/build.Version for anything else). If you point it at an int, the build fails with cannot set with -X: not a var of type string, which is fine. But if you misspell the name, the build succeeds and nothing is set. And if the variable is initialised by a function call, as in var v = os.Getenv("V"), that call runs when the program starts and replaces whatever the linker wrote. For -X to stick, the variable should be empty or initialised to a constant string.

Who can read a linked variable?

Go back to the question of when linking happens. The value you pass to -X is written into the binary as ordinary bytes. Anyone with a copy of the binary can therefore read it, without the source and without any Go tooling beyond what’s already on their machine:

A terminal session. The binary is built with -s -w and -X main.apiKey=sk_live_SUPERSECRET. Running strings on it prints sk_live_SUPERSECRET, and go version -m prints the full -ldflags including the key.

In fact it appears twice. Once as the string itself, which strings will find. And again inside the build information: since Go 1.18 the binary records the flags it was built with, -ldflags included, and go version -m prints them. Stripping with -s -w doesn’t change this, since those flags remove the symbol table and debug information, not string data. Neither does -trimpath.

So a linked variable should be thought of as public. If you’d be uncomfortable seeing a value on a download page, it shouldn’t go through -X. The binary isn’t the only place it would leak, either: the same value usually sits in CI logs, in a Makefile, and in image layers. A leaked secret can be rotated, but the binaries that contain it can’t be recalled.

So what is -X good for?

Values that describe the build itself, are fine to be public, and shouldn’t change once the build is done:

Reasonable use of -XWhy it fits
Version, commit, build datePublic facts about the build that shouldn’t change after it
Edition or distribution name (“oss”, “enterprise”)The builds really are different, and the label says so
Default public endpoints for a CLIPublic anyway, as long as a flag or env var can still override it
Which pipeline produced the binaryUseful when a support ticket arrives with a binary attached

And it’s a poor fit for anything secret, anything that varies between environments, or anything you might want to change without rebuilding:

Poor use of -XBetter
API keys, tokens, passwords, signing keysA secret manager or environment variables, read at runtime
Per-environment config (staging vs production URLs)Flags, env vars or config files, with one binary promoted through environments
Feature flagsA feature flag service; changing a linked value means rebuilding
Licence checksNot this: the expected value is readable with strings
Anything that isn’t a stringParse a string at startup, or use a proper config source

The per-environment case is the one I see most often. If the staging and production binaries differ by an -X value, then the binary you tested isn’t quite the binary you shipped. Building once and configuring at runtime avoids the question entirely.

Does Go always use its own linker?

By default, yes, and the result is a statically linked binary with no dependency on the system C library, which is much of the reason Go binaries are easy to copy around. Once cgo is involved, though, Go hands the final step to the system linker (gcc or clang), because it now has C objects to deal with. You can make the choice explicit:

go build -ldflags "-linkmode=external -extldflags=-static" .Code language: JavaScript (javascript)

-linkmode selects internal or external, and -extldflags passes flags through to the system linker.

The linker can also produce things other than executables. -buildmode selects which:

ModeOutput
exeA standard executable (the default)
pieA position-independent executable, the default on some platforms
c-archiveA static library callable from C
c-sharedA shared library (.so, .dylib, .dll) callable from C
pluginA Go plugin loadable with plugin.Open

Which flags are worth remembering?

FlagEffect
-X pkg.var=valSet a string variable (readable by anyone, as above)
-sOmit the symbol table
-wOmit DWARF debug information
-linkmodeinternal or external
-extldflagsFlags for the external linker
-buildidOverride the build ID
-dumpdepPrint the reachability graph, one edge per line

-dumpdep is the one to reach for when a binary is bigger than you expected. It prints every edge the linker followed while deciding what to keep, as caller -> callee, so you can trace how a package got pulled in:

go build -ldflags=-dumpdep . 2>&1 | grep encoding/json

And to look at what the linker produced:

go tool nm app | grep main.       # symbols and their addresses
go tool objdump -s main.main app  # disassemble one function
go version -m app                 # embedded build info, -ldflags included
strings app | less                # what anyone with the file can seeCode language: PHP (php)

The last command is worth running on your own release binaries now and then. It’s a quick check on what you’re actually shipping.

In short

The compiler produces one archive per package, each with gaps where other packages should be. The linker fills those gaps, leaves out code nothing reaches, and adds the tables the runtime needs. It does this once, at build time, and the result is a file that you then give to other people. Anything the linker wrote into that file, including every -X value, goes with it.

Raveesh Agarwal is a full stack builder writing about Kubernetes, agentic AI and distributed systems. He runs a four-node Kubernetes cluster on his desk. More about me · Follow via RSS

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *