The Linker — Junior¶
You wrote package main, ran go build, and got a single file you can run. The linker is the last stage of that journey: it takes all the compiled pieces and glues them into one executable. This page explains what it does in plain terms and shows you two tricks you'll use forever: stripping a binary to make it smaller, and stamping a version into it at build time.
1. What a linker actually does¶
The Go compiler does not produce an executable. It produces object files (one per package, with the .o / .a extension internally). Each object file is incomplete: it references functions and variables that live in other packages. For example your main.main calls fmt.Println, but the code for fmt.Println lives in a different object file.
The linker's job is to:
- Load every object file and archive your program needs.
- Resolve symbols — connect "I call
fmt.Println" to "here is the actual machine code forfmt.Println." - Throw away everything that's never used (dead-code elimination).
- Lay out the surviving code and data into one file with the right format for your operating system.
- Write that file to disk — your executable.
compiler linker
-------- ------
main.go → main.o ─┐
fmt/*.go → fmt.a ├─► cmd/link ─► ./myapp (one runnable file)
runtime → runtime.a┘
In Go this linker is cmd/link, part of the Go toolchain itself. You almost never run it by hand — go build calls it for you. But you can: go tool link.
2. Internal vs external linking (the simple version)¶
There are two ways Go can produce the final binary:
| Mode | Who does the linking | When it's used |
|---|---|---|
| Internal (default) | Go's own linker (cmd/link) | Pure-Go programs |
| External | The system linker (ld/clang) | When you use cgo, or certain build modes |
By default Go uses internal linking — it does not call the system ld. This is why a pure-Go program builds the same on a machine with no C toolchain installed. The moment you import C code (cgo), Go must hand off to the system linker because only it knows how to link the C object files. We'll go deeper in later tiers; for now just remember: pure Go = internal, cgo = usually external.
3. Strip a binary and watch it shrink¶
A normal Go binary carries extra baggage: a symbol table (names of all your functions) and DWARF debug information (so debuggers can map machine code back to source lines). You don't need either to run the program — only to debug it.
-ldflags lets you pass options to the linker. Two flags strip the fat:
-s— omit the symbol table and debug info.-w— omit the DWARF debug info specifically.
# Build normally
go build -o app-full ./...
# Build stripped
go build -ldflags="-s -w" -o app-stripped ./...
# Compare sizes
ls -lh app-full app-stripped
Typical result: the stripped binary is often 20–30% smaller.
The tradeoff: stripped binaries are harder to debug, and stack traces lose some detail (we'll cover exactly what survives in the middle/senior tiers — the function-name table for panics is separate and usually still there).
4. Stamp a version into the binary with -X¶
You want myapp --version to print the exact git commit it was built from — without editing source each time. The -X linker flag sets a string variable at link time.
First, declare a package-level string variable (it must be a var, must be a string, and must be left unset or set to a default):
package main
import "fmt"
// Set at build time via -ldflags "-X main.version=..."
var version = "dev"
func main() {
fmt.Println("version:", version)
}
Then build with -X importpath.name=value:
Real pipelines pull the value from git:
You can combine flags. Quote the whole thing:
5. Common misconceptions¶
- "Go uses the system linker like C does." No — by default Go has its own linker and does not need
ld. Only cgo/some build modes pull in the system linker. - "
-Xcan set any variable." It only sets package-levelstringvariables. You can't set anint, abool, or a local variable. (Convert later in code if you need a number.) - "
-X main.Versionworks for any file." The path must be the import path of the package, not the filename. Forpackage mainin your module it's usuallymain.Version; for a sub-package it'sgithub.com/you/app/build.Version. - "
-s -wbreaks panics / stack traces." Stack traces with function names still work after-s -w, because Go keeps a separate function table (.gopclntab). What you lose is full debugger support (DWARF) and the classic symbol table used by some external tools. - "Stripping makes it run faster." It only makes the file smaller. Runtime speed is unchanged.
6. Things to do today¶
- Build any program twice — once normally, once with
-ldflags="-s -w"— and record the size difference withls -lh. - Add a
var version = "dev"to a tinymain.goand stamp it with-X main.version=$(git describe --tags --always). - Run
go tool nm app | headon an unstripped binary to see the symbol names the linker produced. - Build the same program with
-ldflags="-s -w"and rungo tool nmagain — watch most symbols disappear. - Run
go version -m ./appto see the build info the linker embedded (module versions, build flags).
7. Summary¶
- The linker (
cmd/link) is the final build stage: it loads object files, resolves symbols, drops dead code, lays out sections, and writes the OS-native executable. - Go uses internal linking by default (its own linker, no system
ld); cgo and some build modes switch to external linking. -ldflags="-s -w"strips the symbol table and DWARF to shrink the binary.-ldflags="-X importpath.name=value"sets a package-level string variable at link time — the standard way to stamp versions.- Inspect results with
go tool nmandgo version -m.