Static and Dynamic Linking in Go
By default the
gcGo toolchain produces a statically linked executable: every package the program imports — including the Go runtime, the garbage collector, and the reflection metadata — is baked into a single file that depends on no external shared object at run time (Go FAQ). This is unusual for a modern language toolchain and is a deliberate design choice: a statically linked binary copies cleanly to a bare container or a fresh host with noLD_LIBRARY_PATHsurprises. The picture changes the moment a program uses cgo, opens abuildmode=plugin, or links against system libraries that are only available as shared objects — then the Go linker must hand part of the job to an external C linker and the result may carry a dynamic dependency. The two axes worth keeping separate are link mode (who does the linking — Go’s internal linker or an externalcc) and linkage (static vs dynamic — whether the finished binary needs.so/.dllfiles at run time).
Mental Model
The cleanest way to think about Go linking is as a two-stage decision. First, the compiler (cmd/compile) turns each package into an object file — a .o-equivalent archived inside the build cache. Then cmd/link (the Go linker) takes the object file for main plus every transitively imported package and resolves them into one address space: it lays out the .text, .rodata, .data, and .bss sections, fixes up every symbol reference, writes the runtime bootstrap, and emits an executable. When no C code is involved, cmd/link does this entirely on its own — internal linking — and the output is a fully static binary.
When the program contains C code (via cgo) or must be linked into a non-Go artifact, cmd/link cannot finish alone, because C object files use the platform’s native object format and calling conventions that only a real C toolchain understands. In that case the Go linker pre-links the Go half into a single combined object and then invokes an external linker (gcc or clang) to merge in the C objects and produce the final binary — external linking (cmd/link docs). External linking is what introduces dynamic dependencies, because the C standard library (libc) is normally only available as a shared object.
flowchart TD A[Go source packages] --> B[cmd/compile<br/>per-package object files] B --> C{Any C code?<br/>cgo / buildmode} C -->|No| D[Internal linking<br/>cmd/link alone] C -->|Yes| E[External linking<br/>cmd/link pre-links Go,<br/>then invokes gcc/clang] D --> F[Static executable<br/>no .so at runtime] E --> G[Often dynamically linked<br/>needs libc.so etc.] E -.CGO_ENABLED=0 or<br/>-extldflags=-static.-> F
Diagram: the link-mode decision tree. The insight to extract is that “static vs dynamic” is downstream of “internal vs external” — internal linking is always static, while external linking is dynamic by default but can be forced static. The dashed edge shows the escape hatches that pull external linking back to a static result.
Mechanical Walk-through
Consider the lifecycle of go build. After cmd/compile finishes, every package archive lands in the build cache. cmd/link is then invoked with the import graph. Its first job is symbol resolution: every CALL to fmt.Println, every reference to a global like runtime.g0, is a named symbol that must be bound to an address. The linker walks the object files, builds a symbol table, places each symbol’s data into the appropriate output section, and rewrites the relocation entries (a relocation says “patch the four bytes here with the final address of symbol X”).
The decision of internal vs external linking is governed by the -linkmode flag, which has three settings — internal, external, and auto — and auto is the default (cmd/link docs). Under auto the linker starts from internal as the baseline and escalates to external only when forced. The actual decision lives in determineLinkMode in src/cmd/link/internal/ld/config.go, whose core is if extNeeded || (iscgo && (externalobj || preferExternal)) { ctxt.LinkMode = LinkExternal } else { ctxt.LinkMode = LinkInternal } (config.go). Reading that condition symbol by symbol: extNeeded is the result of mustLinkExternal (a hard requirement — see below); iscgo is true when any cgo object is present; externalobj is true when a host object file (e.g. a precompiled C archive) is among the inputs; and preferExternal reflects the GO_EXTLINK_ENABLED toolchain default. The crucial nuance most people get wrong: cgo alone does not force external linking. Per cmd/cgo/doc.go, “if the only packages using cgo are those on a list of known standard library packages (net, os/user, runtime/cgo), cmd/link will use internal linking mode. Otherwise, there are non-standard cgo packages involved, and cmd/link will use external linking mode” (cmd/cgo). So a program that uses net (which is cgo-backed by default) still links internally; it is the non-standard cgo package — mattn/go-sqlite3, an import "C" block in your own code — that brings a host object file and trips externalobj.
The hard requirements that set extNeeded come from mustLinkExternal: the memory and address sanitizers (-msan, -asan); a -buildmode the internal linker cannot emit (c-archive, c-shared except on wasm, shared, plugin); a pie buildmode on a platform whose internal linker lacks position-independent support; and platform/architecture combinations that mandate it (e.g. cgo on MIPS, on Android, on Dragonfly; iOS/arm64 always) (config.go, internal/platform). When external linking is selected, the Go linker writes a single combined go.o object and shells out to the C compiler driver — clang or gcc by default (the -extld flag overrides it) — passing any flags collected from #cgo LDFLAGS directives plus the -extldflags linker flag.
The second axis, static vs dynamic linkage, is mostly a consequence of the first. A purely internal-linked Go binary has no dynamic header dependency on any library — the only thing it needs is the OS kernel’s system-call interface. (On Linux the binary may still be marked as a dynamic executable: the -d flag of cmd/link controls whether a dynamic header is emitted, and the header is on by default even without dynamic dependencies because many system tools now expect one. Emitting the header does not add a runtime dependency — it is the difference between “is an ET_DYN/ET_EXEC with a PT_INTERP” and actually needing libraries (cmd/link docs).) An external-linked binary, by contrast, is dynamic by default: gcc links against the shared libc, so the finished file has a DT_NEEDED entry for libc.so.6 and a PT_INTERP pointing at the dynamic loader (ld-linux-x86-64.so.2).
The way to make an external-linked binary static again is to tell the external linker to link libc statically: go build -ldflags '-extldflags "-static"'. This requires a static libc.a to be installed (musl-based distributions and glibc-static packages provide one). The cleaner route, when no actual C code is needed, is to disable cgo entirely with the CGO_ENABLED=0 environment variable; that forces internal linking and a pure-static result, at the cost of swapping cgo-backed standard-library implementations for pure-Go ones (most visibly the net and os/user packages, which fall back to pure-Go DNS resolution and /etc/passwd parsing).
Code and Configuration Examples
A pure-Go program, built and inspected:
# 1. Build with cgo explicitly disabled — guarantees internal linking.
CGO_ENABLED=0 go build -o hello-static ./cmd/hello
# 2. Confirm there are no dynamic dependencies.
ldd hello-static
# -> "not a dynamic executable" (or "statically linked")
# 3. Inspect the link decision the toolchain actually made.
go build -ldflags=-v -o hello ./cmd/hello # -v makes the linker log its modeLine 1 sets CGO_ENABLED=0; this both disables compilation of any import "C" blocks (a build error if cgo is genuinely required) and selects the pure-Go variants of standard-library packages, guaranteeing -linkmode=internal. Line 2 runs ldd — for a static binary it reports no shared-object dependencies. Line 3 passes -v to the linker via -ldflags, which causes cmd/link to print which mode it chose.
Now a cgo program and the flags that control its linkage:
# Default: cgo present -> external linking -> dynamically linked binary.
go build -o app-dyn ./cmd/app
ldd app-dyn
# linux-vdso.so.1
# libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6
# /lib64/ld-linux-x86-64.so.2
# Force a fully static binary even with cgo, by statically linking libc.
go build -ldflags '-linkmode external -extldflags "-static"' -o app-static ./cmd/app
ldd app-static
# -> "not a dynamic executable"The first build accepts the defaults: cgo is in use, so -linkmode=auto resolves to external, gcc is invoked, and the result links libc dynamically — ldd lists libc.so.6 and the loader. The second build pins -linkmode external (explicit, though auto would pick it anyway) and threads -static through to the external linker via -extldflags; gcc then pulls libc.a instead of libc.so, and the output has no runtime library dependency.
Stripping debug information to shrink the binary — orthogonal to linkage but commonly bundled in:
go build -ldflags '-s -w' -o app-small ./cmd/appThe -w flag tells cmd/link to omit the DWARF symbol table; -s omits the regular symbol table and implies -w (cmd/link docs). Neither changes whether the binary is static or dynamic — they only remove metadata used by debuggers and panic-time symbolization. A -s -w build typically drops 25–30% of the file size; the trade-off is that stack traces still resolve (the runtime keeps its own minimal PC-to-line table) but delve and gdb lose source-level debugging.
The -buildmode flag selects the artifact shape and indirectly the link mode (cmd/link docs):
go build -buildmode=exe ... # default: ordinary executable
go build -buildmode=pie ... # position-independent executable (ASLR-friendly)
go build -buildmode=c-archive ... # .a static library callable from C
go build -buildmode=c-shared ... # .so/.dll shared library callable from C
go build -buildmode=shared ... # Go shared library, shared between Go programs
go build -buildmode=plugin ... # loadable plugin for the plugin packageexe is the static default. c-archive, c-shared, shared, and plugin all force external linking because they must interoperate with a native object/loader format. pie produces a position-independent executable so the OS can apply Address Space Layout Randomization to the whole image. Whether pie forces external linking is platform-dependent and resolved by InternalLinkPIESupported in src/internal/platform/supported.go: as of the current tree the internal linker can emit PIE for android/arm64, darwin/amd64, darwin/arm64, linux/amd64, linux/arm64, linux/loong64, linux/ppc64, linux/ppc64le, linux/s390x, and windows/386, windows/amd64, windows/arm64 (internal/platform). On any other GOOS/GOARCH, mustLinkExternal reports pie as requiring external linking because “internal linking does not support TLS_IE” (the initial-exec thread-local-storage relocation a PIE needs) (config.go). So on the common linux/amd64 target a -buildmode=pie build links internally and stays static; on, say, linux/riscv64 it does not.
Failure Modes and Common Misunderstandings
The single most common production surprise is a binary that runs on the build host but fails on the deploy host with an error like ./app: error while loading shared libraries: libc.so.6: version 'GLIBC_2.34' not found. This happens when cgo silently triggered external+dynamic linking, the build host had a newer glibc, and the deploy host’s glibc is older. The fix is either CGO_ENABLED=0 (if no C code is genuinely needed) or building against the oldest glibc you must support — frequently done inside an old base image.
A second misunderstanding is believing CGO_ENABLED=0 is purely a linkage knob. It also changes behavior: with cgo disabled, the net package uses the pure-Go DNS resolver instead of the system’s getaddrinfo, which can resolve names differently (it ignores some /etc/nsswitch.conf configuration). Programs that depend on enterprise DNS plugins or LDAP-backed nss modules can break in subtle ways when switched to a static build.
A third trap is the assumption that a “static” Go binary has zero OS coupling. It still depends on the kernel’s system-call ABI. A binary built on a host with a Linux 6.x kernel can use system calls (or syscall variants) absent on an old 3.x kernel; Go’s documented minimum for Go 1.24 and later is Linux kernel 3.2 or later (Go Minimum Requirements), and going below that is undefined.
Finally, glibc’s static linking is officially semi-supported: even with -extldflags=-static, code paths inside glibc that use dlopen (notably NSS) will warn at link time and may misbehave at run time. This is why fully static cgo binaries are most reliably produced against musl libc (e.g., on Alpine Linux), which has no such dlopen dependency.
Alternatives and When to Choose Them
The first decision is internal vs external linking, and the rule is simple: prefer internal (static) whenever you do not genuinely need C interop. It yields a self-contained binary, faster links, smaller deploy surface, and trivially correct container images (FROM scratch works). Use external linking only when forced — real cgo dependencies, c-shared/c-archive artifacts, or PIE on platforms that require it.
When cgo is required, the second decision is dynamic vs static libc. Dynamic linking (the default) keeps the binary small and lets the host patch libc security fixes without rebuilding; it costs you portability across glibc versions. Static libc linking yields portability at the cost of a larger binary, the glibc/musl caveats above, and the loss of host-level libc patching. The pragmatic middle ground for cgo-free programs is CGO_ENABLED=0 — full static linking with no libc at all.
A distinct alternative is -buildmode=pie, which is not about static vs dynamic dependencies but about exploit mitigation: a position-independent executable can be loaded at a randomized base address, defeating return-oriented-programming attacks that hardcode addresses. Some Linux distributions (and the Go team’s own hardened builds) default to PIE. The cost is a small indirection overhead on global accesses and, on platforms outside the internal-PIE support list above, mandatory external linking — though on the dominant linux/amd64, linux/arm64, and darwin targets the internal linker handles PIE itself, so a PIE build there remains static.
Production Notes
Container images are where Go’s static-by-default linking pays off most visibly. A CGO_ENABLED=0 build can run in a FROM scratch image — literally just the binary and perhaps a CA-certificate bundle — producing images of a few megabytes with a near-zero CVE surface, because there is no libc, no shell, no package manager to be vulnerable. This is the standard pattern for Go microservices and is one reason Go became dominant in the cloud-native ecosystem.
The cross-compilation story reinforces the same point. Because internal linking needs no host C toolchain, GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build cross-compiles from an amd64 macOS laptop to a Linux ARM64 binary with no extra setup — see Cross Compilation in Go. The moment cgo enters the picture, cross-compilation requires a full cross C toolchain for the target, which is the main practical reason large Go codebases work hard to keep CGO_ENABLED=0 viable.
For binary-size-sensitive deployments (embedded, edge, large fleets), the standard recipe is CGO_ENABLED=0 go build -ldflags '-s -w' -trimpath: static linking, stripped symbol tables, and -trimpath to remove local filesystem paths from the binary. Further reductions are possible with post-processing tools like UPX compression, though that trades startup time and is generally discouraged for servers.
See Also
- The Go Linker — the full architecture of
cmd/link, of which link mode is one facet - Go Executable Layout — what the finished ELF/Mach-O/PE actually contains
- Go Object Files and Archives — the per-package inputs the linker consumes
- cgo Internals — why C interop forces external linking
- cgo Performance and Pitfalls — the runtime cost that travels with external linking
- Cross Compilation in Go — internal linking is what makes zero-setup cross builds possible
- Build Constraints and Conditional Compilation — how
CGO_ENABLEDselects package variants - Go Internals MOC — parent map, Section 3 (Linker, Build, and Object Files)