Static vs Dynamic Linking

Linking is the act of resolving the symbolic references in your code — a call to printf, a use of errno — 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 linker ld.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

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:

  1. No PT_INTERP program header. A dynamic executable contains a small PT_INTERP segment holding the path of its interpreter — on x86-64 glibc, /lib64/ld-linux-x86-64.so.2 (ld.so(8)). The kernel’s execve reads this and maps that file, transferring control to it rather than to the program. A fully static binary has no PT_INTERP, so execve maps only the program’s own segments and jumps directly to its _start. There is no dynamic linker in the process at all.
  2. No DT_NEEDED entries. The dynamic section (.dynamic, PT_DYNAMIC) that lists shared-library dependencies via DT_NEEDED tags is empty or absent — nothing to load.
  3. 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.)

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, and gethostbyname resolve users and hosts through pluggable modules (libnss_files.so, libnss_dns.so, libnss_systemd.so) named in /etc/nsswitch.conf. glibc loads these with dlopen at run time. A statically linked glibc program therefore still needs those .so files 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.
  • dlopen itself, locales, and iconv. A statically linked glibc binary that calls dlopen (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.c produced a non-PIE ET_EXEC binary 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 via redhat-hardened-cc1/redhat-hardened-ld spec files, not by the compiler’s own default. Reason: prior vault notes recorded “Fedora default = PIE” without distinguishing the rpm-hardening policy from the bare gcc default, and this build contradicts the blanket claim. To resolve: check gcc -v for --enable-default-pie on 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_INTERP
  • ldd printing not a dynamic executable (or statically linked) is the fastest confirmation of a static build; linux-vdso.so.1 in 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 — being DYN is about relocatability, not about needing ld.so.

Failure Modes

  • cannot find -lc at link time — the static glibc archive isn’t installed; install glibc-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/getaddrinfo returns nothing or crashes because the target lacks the matching libnss_*.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_NEEDED library is missing from the search path; diagnose with ldd and LD_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