The Linker — Professional¶
This is the production playbook: reproducible builds, version-stamping pipelines, size budgets, the buildmodes you actually ship (plugin, c-shared, PIE), the static-vs-dynamic cgo decision, and the footguns that have caused real outages. Everything here assumes you own the build/release pipeline.
1. Reproducible builds¶
A reproducible build means: the same source + same toolchain ⇒ byte-identical binary, on any machine, any directory, any user. This matters for supply-chain security (you can verify a published binary matches the source) and for caching.
The two things that break reproducibility by default:
- Absolute paths embedded in pclntab/DWARF — fixed by
-trimpath. - Build metadata drift — controlled via
buildinfoand pinned toolchain.
Notes:
-trimpathremoves the machine'sGOPATH/module root from embedded paths.-buildid=(empty) clears the content-hash build ID the linker normally stamps; it can vary and defeat bit-for-bit comparison in some pipelines. Only clear it when you specifically need byte-identity — it disables build caching benefits.- Pin the Go toolchain version (e.g. via
go.mod'sgodirective +GOTOOLCHAIN); a different compiler/linker version produces different bytes. - Avoid embedding timestamps.
go builditself doesn't embed wall-clock time; your-Xvalues must not includedate.
Verify reproducibility:
go build -trimpath -ldflags="-s -w -buildid=" -o a1 ./cmd/app
go build -trimpath -ldflags="-s -w -buildid=" -o a2 ./cmd/app
sha256sum a1 a2 # must match
2. Build info — what's baked in, and how to read it¶
The linker embeds a .go.buildinfo section: module path, main module version, dependency versions, and selected build settings. Read it without running the binary:
go version -m ./app
# ./app: go1.22.3
# path github.com/you/app/cmd/app
# mod github.com/you/app v1.4.2 h1:...
# dep github.com/x/y v0.3.1 h1:...
# build -trimpath=true
# build -ldflags="-s -w -X main.version=1.4.2"
# build vcs.revision=abcdef1
# build vcs.time=2026-06-01T...
Since Go 1.18 the toolchain also stamps VCS info (vcs.revision, vcs.time, vcs.modified) automatically when building from a git checkout — you often don't need -X for the commit anymore. Read it at runtime:
import "runtime/debug"
func version() string {
if bi, ok := debug.ReadBuildInfo(); ok {
for _, s := range bi.Settings {
if s.Key == "vcs.revision" {
return s.Value
}
}
}
return "unknown"
}
Or parse another binary's info programmatically with debug/buildinfo:
Footgun: building in a CI container that does a shallow/dirty checkout sets
vcs.modified=trueor drops VCS stamping (e.g. when.gitis absent or-buildvcs=false). If your release relies onvcs.revision, ensure the build context has.gitand a clean tree, or fall back to-X.
3. Version-stamping pipeline (battle-tested pattern)¶
VERSION := $(shell git describe --tags --always --dirty)
COMMIT := $(shell git rev-parse --short HEAD)
DATE := $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
PKG := github.com/you/app/internal/build
LDFLAGS := -s -w \
-X '$(PKG).Version=$(VERSION)' \
-X '$(PKG).Commit=$(COMMIT)' \
-X '$(PKG).Date=$(DATE)'
build:
go build -trimpath -ldflags="$(LDFLAGS)" -o bin/app ./cmd/app
Rules that prevent the most common failures:
- The target variables must be package-level
varof typestringininternal/build. Notconst, not local, notint. - Use the full import path:
github.com/you/app/internal/build.Version, notbuild.Version. - Quote each
-Xseparately when the value has spaces (the date). - Don't put
-Xon aconst— the linker silently ignores it (no error), which is why people stare atversion = "dev"for an hour.
For reproducible builds, don't stamp date — let buildinfo carry vcs.time instead, or pass a fixed SOURCE_DATE_EPOCH.
4. Size budgets and analysis¶
Treat binary size as a tracked metric (e.g. fail CI if it grows > N%).
# Baseline
go build -ldflags="-s -w" -trimpath -o app ./cmd/app
ls -l app
# Rank symbols by size
go tool nm -size -sort size app | tail -30
# Per-package / section breakdown with bloaty (https://github.com/google/bloaty)
bloaty app # sections + symbols overview
bloaty -d sections app
bloaty -d symbols app | head -30
bloaty -d compileunits app # which packages cost the most
# Dependency reachability dump from the linker itself
go build -ldflags="-dumpdep" -o app ./cmd/app 2>deps.txt
wc -l deps.txt # the live symbol dependency edges
-ldflags=-dumpdep makes the linker print the dependency edges it followed during deadcode — invaluable for answering "why is regexp in my binary?" Grep the output for a symbol and walk backwards to the root that kept it alive.
What typically dominates size: reflect + the type metadata of everything it touches, net/http + TLS (crypto/...), regexp, time/tzdata (if embedded), and large generated code.
5. Buildmodes you ship¶
-buildmode=pie¶
Position-independent executable; pages can load at random addresses (ASLR). Some distros (Alpine hardened, certain Linux policies) expect PIE. It usually forces external linking and slightly larger binaries. Use when your security baseline requires it:
-buildmode=plugin¶
Builds a .so you load at runtime with the plugin package (Linux/macOS only; no Windows). Powerful but fragile:
p, err := plugin.Open("handler.so")
sym, err := p.Lookup("Handler")
h := sym.(func(http.ResponseWriter, *http.Request))
Footgun (real, common): the plugin and the host must be built with the exact same Go version, the same versions of every shared dependency, the same
GOOS/GOARCH, and compatible build flags. Any skew yieldsplugin was built with a different version of package .... Plugins also can't be unloaded and leak the whole transitive dependency set into the host. Most teams who try plugins eventually replace them with subprocess + RPC or WASM.
-buildmode=c-shared / c-archive¶
Expose Go to C / Python / other runtimes. c-shared makes a .so/.dll; c-archive makes a .a for static embedding. Exported funcs need //export comments and cgo. These always use external linking.
6. Static vs dynamic with cgo¶
Pure-Go programs are statically linked by default — a single self-contained binary, the reason Go is loved for containers (FROM scratch). The moment cgo is on (e.g. net with the C resolver, os/user, sqlite drivers), you may get a dynamically linked binary that needs libc at runtime — which breaks FROM scratch and Alpine (musl vs glibc).
Decision table:
| Goal | Build |
|---|---|
Static pure-Go (smallest, scratch-friendly) | CGO_ENABLED=0 go build |
| Static even with cgo | CGO_ENABLED=1 go build -ldflags='-linkmode=external -extldflags=-static' (needs static C libs present) |
| Use the pure-Go DNS resolver | CGO_ENABLED=0, or -tags netgo, or GODEBUG=netdns=go |
# Reproducible, static, stripped, version-stamped — the typical container build
CGO_ENABLED=0 go build -trimpath \
-ldflags="-s -w -X main.version=$(git describe --tags --always)" \
-o /out/app ./cmd/app
Footgun:
CGO_ENABLED=1(the default on native builds) + Alpine base image ⇒ binary linked against glibc but Alpine ships musl ⇒not foundat start. SetCGO_ENABLED=0for portable static binaries, or build on/for the right libc.
7. More footguns and real cases¶
-Xset but value isdev— target was aconstor wrong import path, or you put-XinGOFLAGSwhere quoting got mangled. Verify withgo version -m app | grep ldflags.- Stack traces "lost names" after
-w— they didn't; pclntab survives. If names truly vanished, something stripped pclntab post-build (e.g. running the systemstripon the binary). Don't run externalstripon Go binaries; use-s -wat link time. upx-compressed binary flagged by AV / slow startup — UPX trades disk for a decompress-on-exec cost and frequently triggers antivirus heuristics. Avoid for server binaries; maybe acceptable for hand-distributed CLIs.- Plugin
different version of packagein prod — version skew (see above). Pin everything; build host and plugin in one CI job from onego.sum. - Reproducible build diff between CI and laptop — different Go minor version or missing
-trimpath/-buildid=. PinGOTOOLCHAINand comparego version -m. - Binary balloons after adding a "small" dependency — it pulled in
reflector a big subtree. Use-dumpdep/ bloaty to confirm and consider a lighter lib.
8. Summary¶
- Reproducible builds:
-trimpath, optionally-buildid=, pinned toolchain; verify withsha256sumandgo version -m. - Buildinfo + VCS stamping (Go 1.18+) often replaces
-Xfor commit/time; read viaruntime/debugordebug/buildinfo. - Version stamping:
-X full/import/path.Var=value, target must be a package-levelstring var; quote values with spaces. - Size budgets: rank with
nm -size, break down withbloaty, trace with-dumpdep; reflection and crypto/net dominate. - Buildmodes:
piefor hardening,plugin/c-shared/c-archivefor interop — all with sharp version/ABI edges. - cgo + libc decides static vs dynamic: prefer
CGO_ENABLED=0for portable staticscratch/Alpine binaries.