Static vs Dynamic Linking
Linking is the act of resolving the symbolic references in your code — a call to
printf, a use oferrno— to concrete addresses of the code and data that satisfy them. The decisive choice is when that resolution happens and where the satisfying code lives. In static linking the linker copies every needed library routine into the executable at build time, producing one self-contained file that runs with no external dependency and no dynamic linker. In dynamic linking the executable instead records the names of the shared objects it needs (libc.so.6) and defers resolution to run time, when the kernel maps a helper — the dynamic linkerld.so— that finds those libraries on disk, maps them, and binds the symbols. This one decision cascades into binary size, startup cost, memory sharing, security-update strategy, and container packaging. This note is the generic C/ELF (Executable and Linkable Format) view; the language-runtime spin — Go’s internal-vs-external linking and static-by-default posture — lives in Static and Dynamic Linking in Go, which this note complements rather than repeats.
Mental Model
Think of a program as a set of unfilled blanks — every reference to a function or global it does not itself define. Static linking fills every blank with a physical copy of the answer, bakes the result into the file, and hands you a binary that will run identically on any kernel of the right architecture forever. Dynamic linking leaves the blanks as labelled slots (printf@GLIBC_2.2.5) and ships an instruction sheet (DT_NEEDED: libc.so.6) telling a run-time agent where to find the fill-ins; the agent — ld.so, named in the ELF PT_INTERP program header — does the filling every time the program starts, through the indirection tables (PLT and GOT).
flowchart TB subgraph STAT["Static executable (self-contained)"] S_code["your .text + .data"] S_libc["copied-in libc routines<br/>(printf, malloc, ...)"] S_code --- S_libc S_note["no PT_INTERP · no DT_NEEDED<br/>execve jumps straight to _start"] end subgraph DYN["Dynamic executable"] D_code["your .text + .data"] D_slots["unresolved slots:<br/>printf@GLIBC_2.2.5 (PLT/GOT)"] D_need["DT_NEEDED: libc.so.6<br/>PT_INTERP: /lib64/ld-linux-x86-64.so.2"] D_code --- D_slots --- D_need end DYN -->|execve maps ld.so first| LD["ld.so maps libc.so.6,<br/>relocates, binds symbols"] LD --> LIBC[("shared libc.so.6<br/>(one copy, page-cache-shared<br/>across all processes)")]
What it shows: a static binary carries its own copy of every library routine and needs nothing at run time, so execve goes straight to the C-runtime entry _start; a dynamic binary carries only names plus a PT_INTERP pointer to ld.so, which at every launch maps the shared libc.so.6 and fills the empty PLT/GOT slots. The insight: the static binary trades disk size and update-flexibility for zero run-time linking machinery, while the dynamic one trades a per-launch resolution cost for one shared, independently-updatable copy of libc across the whole system.
Mechanical Walk-through
What a static link produces
When you pass -static to the compiler driver, the static linker (ld, GNU BFD or gold, or LLVM lld) pulls object members out of the archive versions of the libraries (libc.a, not libc.so) and merges them into the output. The result has three tell-tale properties, all directly observable with readelf:
- No
PT_INTERPprogram header. A dynamic executable contains a smallPT_INTERPsegment holding the path of its interpreter — on x86-64 glibc,/lib64/ld-linux-x86-64.so.2(ld.so(8)). The kernel’sexecvereads this and maps that file, transferring control to it rather than to the program. A fully static binary has noPT_INTERP, soexecvemaps only the program’s own segments and jumps directly to its_start. There is no dynamic linker in the process at all. - No
DT_NEEDEDentries. The dynamic section (.dynamic,PT_DYNAMIC) that lists shared-library dependencies viaDT_NEEDEDtags is empty or absent — nothing to load. - No lazy PLT/GOT binding for library calls. Because every callee’s address is known at link time, calls resolve to fixed offsets; there is no run-time symbol lookup. (A static PIE — below — still has relative relocations for load-address independence, but no symbolic ones.)
What a dynamic link produces and what ld.so does with it
A dynamically linked executable records, for each imported symbol, an undefined .dynsym entry (see The ELF Symbol Table) plus a DT_NEEDED naming the library expected to define it, and it names ld.so in PT_INTERP. At launch the kernel maps ld.so and jumps to it; ld.so then reads DT_NEEDED, searches for each library (via DT_RUNPATH, LD_LIBRARY_PATH, and the ldconfig cache — see Library Search Path and ldconfig), mmaps them, builds the symbol lookup scope (Symbol Resolution and Lookup Scope), applies relocations, and only then calls the program’s entry point. Every one of those steps is skipped by a static binary — which is exactly why static binaries start marginally faster and cannot fail with “library not found” or “version `GLIBC_2.x’ not found” (Versioned Symbols in glibc).
The trade-offs, mechanism by mechanism
Binary size. A static binary embeds every routine it references (and, transitively, everything those reference), so a trivial “hello world” balloons from a few kilobytes to often a megabyte or more once static glibc is pulled in. A dynamic one carries only your code plus the import table. Across a system with hundreds of programs, dynamic linking saves enormous disk and, more importantly, RAM.
Shared memory via the page cache. This is the deepest win of dynamic linking and the one most often forgotten. A shared object’s read-only, position-independent code pages are mapped MAP_SHARED-style (file-backed, copy-on-write for writable data) into every process that uses it, but the kernel keeps a single physical copy in the page cache. One hundred processes linking libc.so.6 share the same physical printf pages. A hundred statically linked programs each carry — and each page in separately — their own copy of printf. On a busy machine this multiplies memory footprint substantially. Drepper’s How To Write Shared Libraries frames the entire discipline of shared libraries around minimizing this per-process cost (Drepper, How To Write Shared Libraries).
Security updates. When a vulnerability is found in libc (say a getaddrinfo overflow), a dynamically linked system fixes it by replacing one file, libc.so.6; every process picks up the fix on its next start, no relink required. A statically linked fleet must rebuild and redeploy every binary that embedded the vulnerable routine. This is the single strongest operational argument for dynamic linking, and the reason distributions link almost everything dynamically.
Startup cost. Dynamic linking pays a per-launch price: mapping ld.so, searching for and mapping each .so, and processing relocations. Lazy Binding defers function symbol resolution until first call to amortize this, but data relocations and the initial mapping are eager. A static binary pays none of this; for short-lived, frequently-forked programs (a shell spawning thousands of tiny tools) the difference is measurable.
Portability and self-containment. A static binary of the right architecture runs on any Linux kernel new enough to provide the syscalls it uses, with no userspace dependency — no matching libc, no ld.so, nothing. This is the property that makes static binaries the darling of container images and single-file distribution.
The glibc Static-Linking Caveats
Static linking with glibc specifically carries footguns that static linking with musl or in Go does not, and the glibc maintainers actively discourage it. Two mechanisms inside glibc call back into shared objects even in a “static” binary:
- The Name Service Switch (NSS). Functions like
getpwnam,getaddrinfo, andgethostbynameresolve users and hosts through pluggable modules (libnss_files.so,libnss_dns.so,libnss_systemd.so) named in/etc/nsswitch.conf. glibc loads these withdlopenat run time. A statically linked glibc program therefore still needs those.sofiles present at run time to resolve names, and if they are missing (or mismatched against the glibc the binary was built with) name lookups silently fail or crash. The glibc FAQ states plainly that using NSS from a static binary requires the exact same glibc version’s NSS modules at run time (glibc FAQ). “Fully static” glibc is thus a partial fiction wherever NSS is involved. dlopenitself, locales, andiconv. A statically linked glibc binary that callsdlopen(to load a plugin) still drags in the dynamic-loading machinery, and locale data and character-set converters (gconv) are also loaded as external files. The static binary is not as hermetic as it looks.
There is also a build-time caveat that surfaces immediately on modern distributions: glibc’s static archive libc.a is often not installed by default. On this Fedora 44 system (glibc 2.43), gcc -static hello.c fails with:
/usr/bin/ld: cannot find -lc: No such file or directory
have you installed the static version of the c library?
because libc.a ships in a separate glibc-static package that is not part of the base install. (Debian/Ubuntu split it into libc6-dev with the archive present but discourage its use.) This is a deliberate signal that glibc static linking is a non-default, “you’d better know what you’re doing” path.
musl: Static Linking Done Deliberately
musl (an alternative C library used by Alpine Linux and static-first toolchains) was designed from the start for clean static linking (musl FAQ). It has no NSS plugin architecture — name resolution is compiled in — so a static musl binary really is self-contained. musl static binaries are also dramatically smaller than glibc static ones because musl’s own footprint is tiny. This is why the phrase “static binary in a scratch container” is almost always a musl (or Go) story, not a glibc one. See glibc vs musl for the full comparison; the point here is that the library you link against changes whether static linking is a good idea.
Static-PIE: Self-Contained and Address-Randomized
Classic static binaries are loaded at a fixed virtual address, forfeiting Address Space Layout Randomization (ASLR) for the main executable — a security regression. Static-PIE (static Position-Independent Executable, gcc -static-pie) reconciles the two: the binary carries no dynamic dependencies (still no PT_INTERP, no DT_NEEDED) yet is position-independent, so the kernel can load it at a randomized base. It works by including a minimal self-relocation stub — a startup routine that applies the binary’s own relative relocations before running main, doing for itself the tiny slice of ld.so’s job that a PIE needs. glibc has supported static-PIE since 2.27 (2018), though it too requires libc.a to be installed. The position-independence machinery itself is covered in Position-Independent Code and PIE.
Uncertain
Verify: the default linking mode of the packaged system compiler on 2026 distributions. On this Fedora 44 box,
gcc -O2 hello.cproduced a non-PIEET_EXECbinary with only partial RELRO — i.e. the raw locally-built GCC 16.1.1 does not default to PIE. Fedora’s packaged binaries are nonetheless PIE + full-RELRO +-z now, but that hardening is injected by the RPM build viaredhat-hardened-cc1/redhat-hardened-ldspec files, not by the compiler’s own default. Reason: prior vault notes recorded “Fedora default = PIE” without distinguishing the rpm-hardening policy from the baregccdefault, and this build contradicts the blanket claim. To resolve: checkgcc -vfor--enable-default-pieon the exact distro GCC, and read/usr/lib/rpm/redhat/redhat-hardened-*. uncertain
Configuration and Commands
# Prove a binary is dynamic: it names an interpreter.
$ readelf -l ./app | grep -A1 INTERP
INTERP 0x... Requesting program interpreter: /lib64/ld-linux-x86-64.so.2
# List its shared-library dependencies (the DT_NEEDED set, resolved).
$ ldd ./app
linux-vdso.so.1 (0x00007fff...) # the vDSO — always present, not on disk
libc.so.6 => /lib64/libc.so.6 (0x00007f...)
/lib64/ld-linux-x86-64.so.2 (0x00007f...)
# Build a fully static binary (needs glibc-static / libc.a installed):
$ gcc -static -O2 -o app_static app.c
$ readelf -l app_static | grep INTERP # -> (no output: no interpreter)
$ ldd app_static
not a dynamic executable # ldd confirms: nothing to load
# Static-PIE: self-contained AND randomizable.
$ gcc -static-pie -O2 -o app_spie app.c
$ readelf -h app_spie | grep Type
Type: DYN (Position-Independent Executable file) # DYN, yet no PT_INTERPlddprintingnot a dynamic executable(orstatically linked) is the fastest confirmation of a static build;linux-vdso.so.1in the dynamic case is the kernel’s vDSO, which is not a file on disk and is present regardless.- Note the paradox in the last block: a static-PIE reports ELF
Type: DYN(the same type as a shared object and a normal PIE) yet has no interpreter — beingDYNis about relocatability, not about needingld.so.
Failure Modes
cannot find -lcat link time — the static glibc archive isn’t installed; installglibc-static(Fedora) or the equivalent, or reconsider whether you want static glibc at all.- Static glibc binary works on the build host, fails name resolution in production — the classic NSS trap.
getpwnam/getaddrinforeturns nothing or crashes because the target lacks the matchinglibnss_*.so. Symptom: DNS works via a static musl/Go tool but not via your static glibc tool. Fix: link musl, use Go’s pure-Go resolver, or go dynamic. - Dynamic binary fails to start with “error while loading shared libraries” — a
DT_NEEDEDlibrary is missing from the search path; diagnose withlddandLD_DEBUG=libs. A static binary is immune to this entire class. - Larger attack-surface-update burden with static linking — a libc CVE requires rebuilding every static binary. Teams that ship static binaries need a rebuild-and-redeploy pipeline ready, not an
apt upgrade libc6.
Alternatives and When to Choose Them
Choose dynamic linking (the distribution default) for anything installed via a package manager on a shared system: it minimizes RAM through page-cache sharing and lets one libc update fix everyone. Choose static linking for single-file distribution, minimal container images, rescue/recovery tools that must run when the filesystem is broken, and reproducible “runs anywhere” binaries — but prefer musl or Go for it, not glibc, to sidestep the NSS/dlopen/locale caveats. Choose static-PIE when you want static’s self-containment without losing ASLR. The Go-runtime perspective — where static-by-default is the norm and CGO_ENABLED=0 guarantees a pure-static binary — is the subject of Static and Dynamic Linking in Go.
Production Notes
The container ecosystem made static linking mainstream again. A FROM scratch image containing a single static Go or Rust-musl binary is a few megabytes, has no shell, no package manager, and essentially no attack surface — you cannot exploit a bash that isn’t there. This is why Go’s static-by-default model (it issues raw syscalls and links a copy of its runtime in) is so beloved for microservices. Conversely, the Alpine-Linux gotcha is real: a program dynamically linked against glibc and dropped into an Alpine image (which ships musl as libc.so) crashes at load with confusing errors, because musl is not ABI-compatible with glibc — the .so names collide but the contents do not match. glibc 2.43 (released 2026-01-24, info-gnu announcement) continues to ship the dynamic loader as the primary path; static glibc remains supported-but-discouraged. The rule of thumb that has held for a decade: dynamic for the base system, static (musl/Go) for the container.
See Also
- Static and Dynamic Linking in Go — the language-runtime view: internal vs external linking,
CGO_ENABLED, static-by-default - The Dynamic Linker — what
ld.soactually does at every dynamic launch - The Dynamic Section —
DT_NEEDED,PT_INTERP, and the tags a dynamic binary carries - Library Search Path and ldconfig — how
ld.sofinds the.sofiles a dynamic binary names - Position-Independent Code and PIE — the relocatability machinery behind static-PIE
- The Procedure Linkage Table / The Global Offset Table — the indirection dynamic linking uses; absent in static binaries
- glibc vs musl — why the library you link against decides whether static is wise
- Versioned Symbols in glibc — the “version not found” wall that only dynamic binaries hit
- Linux Userspace Runtime MOC — parent map