{} The Go Reference

Toolchain · Internals · Intermediate

Compile & Link

From source to a binary — the compiler stages (parse, type-check, SSA, codegen), the linker, build modes, and what actually ends up inside a Go executable.

Toolchain Intermediate ⏱ 6 min read Complete

📖 Analogy

Building a Go program is like producing a book from a manuscript. The compiler is the editor working chapter by chapter (package by package): it proofreads grammar (parse + type-check), rewrites for clarity into a clean working draft (SSA + optimization), and typesets each chapter into print-ready pages (machine code, one object file per package). The linker is the bindery: it gathers every typeset chapter plus the standard front-matter everyone needs (the runtime — scheduler, GC, allocator), drops the chapters nobody references (dead-code elimination), and binds it all into a single self-contained volume (the static binary) you can hand to anyone with no extra parts.

go build is a pipeline

go build orchestrates two tools: the compiler (go tool compile), which turns each package’s source into an object file, and the linker (go tool link), which combines all object files — yours, your dependencies’, and the runtime’s — into one executable. The compiler runs per package, in dependency order, and caches results (go build is incremental).

graph LR
S["source .go"] --> P["parse → AST"]
P --> T["type-check"]
T --> I["IR → SSA"]
I --> O["optimize<br/>(inline, escape, etc.)"]
O --> M["machine code<br/>(object file)"]
M --> L["linker"]
R["runtime + deps"] --> L
L --> B["static binary"]

Inside the compiler

For each package the compiler:

  1. Lexes and parses the source into an abstract syntax tree (AST).
  2. Type-checks — the AST gets types; this is where most of your errors surface.
  3. Lowers to an IR and then SSA (static single assignment) — the form where optimizations are easy to express: inlining, escape analysis, dead-store elimination, bounds-check elimination, constant folding.
  4. Generates machine code for the target GOARCH, written into an object archive.

Inside the linker

The linker resolves symbols across all objects, performs dead-code elimination (drops everything unreachable from main/init), lays out the final binary, and embeds the runtime and metadata. The result is statically linked by default — no shared Go library to ship.

What’s in the binary

A Go executable is a self-contained bundle:

  • your compiled code and every imported package’s code,
  • the runtime: the scheduler, garbage collector, and allocator,
  • type metadata for reflection and interface dispatch,
  • the symbol table and DWARF debug info (strippable),
  • embedded files (//go:embed) and build info.

That’s why even “hello world” is a few megabytes — and why deployment is just copy the binary. You can read the embedded build info at runtime:

buildinfo.go — editable & runnable
package main

import (
"fmt"
"runtime"
"runtime/debug"
)

func main() {
fmt.Println("compiler:", runtime.Compiler)
fmt.Println("go version:", runtime.Version())
fmt.Printf("target: %s/%s\n", runtime.GOOS, runtime.GOARCH)

if bi, ok := debug.ReadBuildInfo(); ok {
	fmt.Println("module path:", bi.Path)
	fmt.Println("go directive:", bi.GoVersion)
	fmt.Println("dependencies:", len(bi.Deps))
	// Build settings (e.g. -ldflags, vcs info) live in bi.Settings.
	for _, s := range bi.Settings {
		if s.Key == "GOARCH" || s.Key == "GOOS" || s.Key == "-ldflags" {
			fmt.Printf("  setting %s = %q\n", s.Key, s.Value)
		}
	}
}
}

debug.ReadBuildInfo is how tools like go version -m ./binary show the module graph and VCS stamp baked into any Go binary.

Useful build commands

# Cross-compile: a Linux/arm64 binary from any host, no C toolchain.
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o app .

# Smaller release binary: strip symbol table (-s) and DWARF (-w).
go build -ldflags="-s -w" -o app .

# Stamp a version into a variable at link time.
go build -ldflags="-X main.version=1.4.2" -o app .

# Inspect symbols and sizes.
go tool nm app | head           # symbol table
go version -m app               # module + build settings

# See what the compiler decided (escape/inline), without linking.
go build -gcflags='-m' ./...

Reference

Stage / flagRole
go tool compileSource → object (per package)
go tool linkObjects + runtime → binary
SSAOptimization IR (inline, escape, BCE)
Dead-code eliminationLinker drops unreachable symbols
-gcflagsPass flags to the compiler (-m, -l, -S)
-ldflagsPass flags to the linker (-s -w, -X)
CGO_ENABLED=0Pure-Go, fully static build
GOOS / GOARCHCross-compilation target
//go:embedEmbed files into the binary

🐹 Static binaries are Go's deployment superpower

The headline win: CGO_ENABLED=0 go build gives you one static file that runs on a scratch or distroless container with nothing else installed — no libc, no interpreter, no dependency hell. Combine it with cross-compilation (GOOS/GOARCH) and you build a Linux/arm64 production image from your macOS laptop with one command. For releases, add -ldflags="-s -w" to shrink it and -X to stamp in a version. This is a big part of why Go dominates cloud-native tooling — see the networking & web track for building the services that get shipped this way.

⚠️ cgo, reflection, and stripped symbols have trade-offs

Three things bite people. cgo silently re-enables dynamic linking — importing a package that uses cgo (some DB drivers, anything net with the C resolver) can produce a binary that needs libc on the target; set CGO_ENABLED=0 (and test on the deploy image) when you want true static. Dead-code elimination can’t see through reflection — code only reached via reflect or registered in an init may be kept even when unused, or, conversely, plugins/reflect.New may need symbols the linker would otherwise drop. And -s -w strips the symbol table and DWARF, so production stack traces still work (Go embeds enough for that) but dlv/pprof symbolization and go tool nm degrade — keep an unstripped copy for debugging.

See also

Next: reading the machine code the compiler emits — reading assembly.

Check your understanding

Score: 0 / 5

1. Roughly what stages does the Go compiler run on a package?

go build runs the compiler per package: lex/parse to an AST, type-check, lower to an intermediate form and then SSA (static single assignment) for optimization, and emit architecture-specific machine code into an object archive. The linker then stitches all package objects (plus the runtime) into one executable.

2. Why are Go binaries large and statically linked by default?

A Go binary bundles your code, all imported packages, and the Go runtime into one static executable — there's no libgo.so to ship. That's why a tiny program is a few MB, and why deployment is just 'copy the binary'. CGO_ENABLED=0 keeps it fully static even when net/os/user might otherwise pull in libc.

3. What does the linker's dead-code elimination do?

The linker computes reachability from the entry points and discards unreferenced symbols, so importing a big package but using one function doesn't drag in the whole thing. (Reflection and some init side effects can keep code 'reachable' even if you don't call it directly.)

4. How do you make a smaller release binary by stripping symbols?

-ldflags="-s -w" tells the linker to drop the symbol table (-s) and DWARF debugging information (-w), cutting binary size noticeably. The trade-off is harder debugging and worse stack-trace symbolization, so it's a release-build choice.

5. What is CGO and why does CGO_ENABLED=0 matter for deployment?

cgo bridges Go and C, but it makes the build depend on a C toolchain and can dynamically link libc, breaking 'copy to a scratch image' deployment. Setting CGO_ENABLED=0 forces pure-Go implementations (e.g. the Go DNS resolver) and a fully static binary that runs anywhere.

Comments

Sign in with GitHub to join the discussion.