Go Executable Layout

A compiled Go program is a single, ordinarily statically linked native executable in the host platform’s standard container format — ELF on Linux and the BSDs, PE/COFF on Windows, Mach-O on macOS. What makes a Go binary distinctive is not the container but its contents: alongside the conventional code and data segments, every Go executable carries a substantial body of runtime metadata — the program-counter-to-line table (pclntab) that powers stack traces, type descriptors that drive reflection and interface dispatch, garbage-collector pointer maps, and an embedded record of how the binary was built. This metadata is why a Go binary is larger than an equivalent C binary, why it can print a symbolicated stack trace with no debugger attached, and why go version -m ./binary can recite the exact module versions that went into it (cmd/link source, runtime/debug).

Mental Model

A Go executable is best understood as two layers stacked inside one file: an outer layer that is a perfectly ordinary OS executable, and an inner layer of Go-specific tables that the Go runtime, linked into the same binary, reads at startup and during execution.

The outer layer is what the operating-system loader understands. It is divided into segments, each a contiguous run of the file mapped into memory with uniform permissions: a read-execute text segment (machine code), a read-only rodata segment (constants, string data, the Go metadata tables), a read-write data segment (initialized globals), and a BSS region (zero-initialized globals, which occupy no file space). The loader maps these, jumps to the entry point, and is done.

The inner layer is invisible to the loader but central to Go. The entry point does not go straight to main — it lands in runtime bootstrap assembly (runtime.rt0_go) which initializes the scheduler, the allocator, and the garbage collector, then runs every package’s init functions, then finally calls main.main. To do its job the runtime needs the inner tables: when a goroutine panics, the runtime walks the stack using the pclntab to turn return addresses into file:line pairs; when the GC scans a stack frame, it consults pointer maps to know which words are pointers.

flowchart TD
  subgraph "Go executable (one ELF/PE/Mach-O file)"
    direction TB
    HDR[file header + program headers]
    TEXT[".text — machine code<br/>runtime.text … runtime.etext"]
    RODATA[".rodata — constants, strings,<br/>type descriptors, pclntab,<br/>go:embed data"]
    DATA[".data / .noptrdata — initialized globals"]
    BSS[".bss / .noptrbss — zero globals (no file space)"]
    BUILDINFO[".go.buildinfo — toolchain + module versions"]
  end
  LOADER[OS loader] -->|maps segments| TEXT & RODATA & DATA & BSS
  LOADER -->|jumps to entry| BOOT[runtime.rt0_go]
  BOOT --> SCHED[init scheduler, allocator, GC]
  SCHED --> INITS[run all package init funcs]
  INITS --> MAIN[main.main]
  RT[Go runtime, in .text] -.reads.-> RODATA

Diagram: the OS loader sees only segments and an entry point; the entry point is runtime bootstrap, not main. The Go runtime — itself code in .text — reads the metadata tables in .rodata to do reflection, GC scanning, and stack unwinding. The insight: a Go binary is self-describing — it ships the tables needed to introspect itself.

Mechanical Walk-through

Segments and the standard sections

The Go linker lays the program out into the host container’s sections. In ELF terms the familiar ones are: .text (executable machine code), .rodata (read-only data — string contents, constants, and the Go metadata tables), .data (read-write globals with non-zero initial values), and .bss (read-write globals that start zero — these consume address space but not file bytes, since the loader simply zero-fills the region). PE and Mach-O have equivalent sections under different names.

Go subdivides the data sections by a property the garbage collector cares about: does this region contain pointers? The linker emits .noptrdata and .noptrbss for pointer-free globals and keeps pointer-containing globals separate, so the GC’s root-scanning phase can skip the pointer-free regions entirely instead of examining every global word. This is a recurring theme — the layout is shaped to make GC scanning cheap.

The linker brackets each region with synthetic boundary symbols the runtime uses at startup: runtime.text / runtime.etext mark the start and end of code, runtime.rodata / runtime.erodata the read-only data, runtime.data / runtime.edata, runtime.bss / runtime.ebss. The runtime reads these to know, for example, the exact extent of code (used when deciding whether a return address belongs to Go code) and the extent of globals to scan.

The Go-specific metadata tables

Beyond the standard sections, the linker emits tables that exist only in Go binaries:

pclntab (program counter line table) — emitted into a section conventionally called .gopclntab. It “is a data structure mapping program counters to line numbers” (debug/gosym). Its history is instructive: “In Go 1.1 and earlier, each function had its own LineTable… In Go 1.2, the format changed so that there is a single LineTable for the entire program, shared by all Funcs.” The modern table also encodes, per function, the mapping from PC to function name, to source file, to inlining frames (so an inlined call still appears in traces), and the stack-frame size and pointer maps needed to unwind and GC-scan the frame. This single table is what lets panic() print a symbolicated, multi-frame stack trace with no debugger and no external symbol file — the data is in the binary.

Type information — descriptors for every type used in the program, gathered into the read-only data. A .typelink table indexes all distinct types, and an .itablink table indexes the itabs (interface-method dispatch tables) — the runtime uses these for type assertions, reflect, and interface satisfaction. See Interface Internals and Reflection in Go.

GC pointer maps — bitmaps describing which words of each type, global, and stack frame are pointers. The GC consults these during marking; they are the reason Go’s collector is precise (it knows exactly what is a pointer) rather than conservative.

go:embed data — files embedded via the [[Compiler Directives and Pragmas|//go:embed directive]] are linked in as read-only data; an embed.FS value is backed by these bytes plus a small directory index.

The build-info blob

Every module-built Go binary contains a .go.buildinfo section: a self-describing record of how the binary was built. runtime/debug.ReadBuildInfo reads it from the running process; debug/buildinfo.ReadFile reads it from a file on disk; go version -m ./binary prints it. The BuildInfo struct holds (runtime/debug):

  • GoVersion — the toolchain that built it, e.g. "go1.26.0".
  • Path — the main package’s import path.
  • Main and Deps — the main module and “all the dependency modules, both direct and indirect, that contributed packages to the build,” each with module path and version.
  • Settings — key/value build settings: -buildmode, -compiler, CGO_ENABLED, GOARCH, GOOS, GOAMD64/GOARM/etc., DefaultGODEBUG, and the version-control keys vcs (the VCS used), vcs.revision (the commit hash), vcs.time (commit time, RFC 3339), and vcs.modified (true if the working tree had uncommitted changes).

This is how supply-chain tooling — govulncheck, SBOM generators — can audit a compiled binary: the dependency graph is embedded. The Go 1.26 release notes flag that the linker reorganized some of these sections (.go.module, .gopclntab): “various section reorganizations… that don’t affect running programs but may affect tools analyzing executables” (Go 1.26 release notes) — a reminder that section layout is an implementation detail, while the runtime/debug/debug/buildinfo APIs are the stable contract.

DWARF debug information

By default the linker also emits DWARF debugging sections (.debug_info, .debug_line, and friends) so debuggers like Delve and gdb can map machine instructions to source and inspect variables. DWARF is separate from the pclntab: the runtime uses pclntab for its own traces and never needs DWARF; DWARF exists for external debuggers. go build -ldflags="-w" strips DWARF; -s additionally strips the symbol table. Both shrink the binary at the cost of debuggability — see Failure Modes.

Static linking by default

A pure-Go binary is statically linked: it contains its own runtime, the entire standard library code it uses, and all dependency code, with no dependence on a dynamic loader or external .so/.dll. This is why a CGO_ENABLED=0 Linux binary runs in a scratch container with literally nothing else present. When cgo is involved, or -buildmode=pie is used, the binary may instead be dynamically linked against the C library — the full distinction is covered in Static and Dynamic Linking in Go.

Inspecting a Binary — Commands

# 1. Section layout of an ELF Go binary.
go build -o app ./cmd/app
size --format=sysv app          # per-section sizes
readelf -S app | grep -E 'gopclntab|noptr|text|rodata|buildinfo'
 
# 2. The embedded build info: toolchain, modules, VCS, settings.
go version -m app
 
# 3. Where did the size go? Per-symbol breakdown.
go tool nm --size --sort=size app | tail -25
 
# 4. A smaller binary: strip DWARF (-w) and the symbol table (-s).
go build -ldflags="-s -w" -o app-stripped ./cmd/app
ls -l app app-stripped
 
# 5. Read build info from a file programmatically equivalent of (2).
go run - <<'EOF'
package main
import ("fmt"; "debug/buildinfo")
func main() { bi,_ := buildinfo.ReadFile("app"); fmt.Println(bi.GoVersion, bi.Path) }
EOF

Command 1 shows the section structure: size reports each segment’s footprint, and readelf -S lists the Go-specific sections — .gopclntab is often surprisingly large, because it carries line tables for all linked code including the standard library.

Command 2, go version -m, prints the .go.buildinfo contents: the Go version, the main module path and version, every dependency module@version, and the Settings block (including vcs.revision — the exact commit). This works on any module-built Go binary, even one you did not build, which is its supply-chain value.

Command 3, go tool nm --size, attributes binary size to individual symbols — the standard first step when a binary is “too big.” Typical culprits are reflect, net/http, and large generated tables.

Command 4 shows the -s -w linker flags. -w omits DWARF; -s omits the symbol table. On a typical service binary this removes a meaningful fraction of the size. The pclntab is not removed by either flag — runtime stack traces keep working — which is the right default.

Failure Modes and Common Misunderstandings

-s -w breaks stack traces.” It does not. -s strips the symbol table and -w strips DWARF; neither touches the pclntab, so panic() still prints a fully symbolicated trace. What -s -w does break is external debugging — dlv and gdb lose variable and full source information. Strip for production size, keep an unstripped copy (or symbol server) for debugging.

“Go binaries are huge.” A Go “hello world” is on the order of a couple of megabytes because it statically links the runtime, the GC, the scheduler, and the parts of fmt/runtime it touches, plus the metadata tables. This is a fixed cost, not a per-feature cost — a large program is not proportionally larger. -s -w and upx compression reduce it; the tables themselves are load-bearing and should not be hacked out.

Confusing pclntab with DWARF. They serve different consumers. pclntab is read by the Go runtime itself for its own stack traces and GC; DWARF is read by external debuggers. Stripping DWARF (-w) leaves runtime traces intact; there is no flag to strip pclntab because the runtime depends on it.

Stale or missing VCS info. vcs.revision is only populated when the build runs inside a VCS checkout with the VCS tool available; building from an extracted tarball, or in some CI sandboxes, yields empty VCS settings or vcs.modified=true. go build -buildvcs=false disables VCS stamping explicitly.

Assuming section names are an API. Tools that parse .gopclntab or .go.buildinfo by raw section name are fragile — Go 1.26 reorganized exactly these sections. The supported interfaces are debug/buildinfo, runtime/debug, and debug/gosym; parse through those, not by hand.

Expecting a non-PIE binary everywhere. By default Go produces a standard (non-PIE) executable on most platforms, but some platforms and -buildmode=pie produce a position-independent executable — built from position-independent code so it can load at a randomized base address, which is what enables address-space layout randomization (ASLR) (position-independent code). macOS, in particular, effectively requires PIE. PIE binaries are dynamically linked and slightly larger; see Static and Dynamic Linking in Go.

Alternatives and Comparisons

Compared with a C executable, a Go binary trades size for self-sufficiency: the C binary is small but depends on a system libc and carries no runtime introspection data; the Go binary is larger but self-contained and self-describing. Compared with a JVM or Python program, there is no separate interpreter or VM to install — the “VM” (the Go runtime) is linked in, so deployment is “copy one file.”

The -buildmode flag changes the kind of artifact entirely: exe (the default — a runnable executable), pie (a position-independent executable), c-archive/c-shared (a C-callable static or shared library), shared/plugin (a Go shared object). These are covered under build modes; the layout described in this note is the exe case.

For inspecting layout, the alternatives to the go tool commands are platform-native (readelf/objdump on Linux, otool on macOS, dumpbin on Windows) and third-party visualizers like go-binsize-treemap. They read the same file; the go tool nm/debug/buildinfo path is preferred because it is Go-version-aware.

Production Notes

The embedded build info is the layout feature with the most operational value. govulncheck can scan a compiled binary because the dependency graph is in .go.buildinfo; container-image scanners and SBOM tools do the same. In an incident, go version -m <binary> on a deployed artifact answers “exactly which commit and which dependency versions is this?” without access to the build pipeline — invaluable when a binary outlives its CI logs.

The pclntab is why Go services can ship readable crash reports from production: a panic in a running binary prints file:line for every frame, including inlined ones, with nothing extra installed. Error-reporting systems (Sentry and similar) lean on this. The cost is binary size and a small startup expense to map the tables; the Go team has repeatedly judged the trade worthwhile, and the pclntab is deliberately not strippable.

For binary-size-sensitive deployments — embedded systems, large container fleets, AWS Lambda — the standard levers are -ldflags="-s -w" (drop DWARF and symbols), avoiding heavy dependencies (reflect-driven libraries and net/http pull in a lot), and, for the extreme low end, TinyGo, which uses an LLVM backend and a cut-down runtime to produce binaries orders of magnitude smaller — at the cost of standard-library and concurrency-feature coverage. See gc Compiler vs gccgo vs TinyGo.

See Also