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.

Measuring It — The Same Program, Both Ways

Every trade-off above is a number, and the numbers are worth having rather than gesturing at. Everything in this section was measured on Fedora 44 / x86-64, kernel 7.1.13, glibc 2.43-8.fc44, GCC 16.2.1 20260819, GNU ld (binutils) 2.46.1, strace 7.2, on 2026-09-11. The program is the canonical one:

/* hello.c */
#include <stdio.h>
int main(void) { printf("hello\n"); return 0; }

The first measurement is a build failure, and it is informative. On a stock Fedora 44 install the static link does not even start:

$ gcc -static -O2 -o hello_static hello.c
/usr/bin/ld.bfd: cannot find -lc: No such file or directory
/usr/bin/ld.bfd: have you installed the static version of the c library ?

libc.a lives in a separate glibc-static package that is not part of the base system, and the linker’s error message is a hint aimed squarely at people who did not realise that. For the rest of this section libc.a was obtained by extracting glibc-static-2.43-8.fc44.x86_64.rpm into a scratch directory and putting it on the -L path — deliberately, so the numbers come from the same glibc revision as the shared libc.so.6 they are compared against. Three builds:

$ gcc          -O2 -o hello_dyn    hello.c
$ gcc -static  -O2 -o hello_static hello.c -L<extracted>/usr/lib64
$ gcc -static-pie -fPIE -O2 -o hello_spie hello.c -L<extracted>/usr/lib64

(-static-pie additionally requires -fPIE; without it the link fails with relocation R_X86_64_32 against '.rodata.str1.1' can not be used when making a PIE object, because a static-PIE still needs every relocation to be position-independent.)

Size, shape and dependencies

$ stat -c'%n %s bytes' hello_dyn hello_static hello_spie
hello_dyn         12568 bytes
hello_static    3277096 bytes        <- 261x larger
hello_spie      3344704 bytes        <- 266x larger
 
$ readelf -h <each> | grep Type:
hello_dyn      EXEC (Executable file)
hello_static   EXEC (Executable file)
hello_spie     DYN  (Position-Independent Executable file)
 
$ readelf -lW <each> | grep interpreter
hello_dyn      [Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]
hello_static   (none)
hello_spie     (none)
 
$ ldd hello_dyn                       $ ldd hello_static
  linux-vdso.so.1 (0x...)               not a dynamic executable
  libc.so.6 => /lib64/libc.so.6 (0x...)
  /lib64/ld-linux-x86-64.so.2 (0x...)

The 261× size ratio is the honest headline for a trivial program, and it is also the most misleading number in the comparison, because it is almost entirely fixed overhead: the static binary contains all of printf’s formatting machinery, the locale plumbing it drags in, malloc, and the startup code, regardless of how small main is. A large application’s ratio is far lower. What does not shrink with program size is the per-process cost: a hundred copies of hello_static occupy a hundred private copies of that 3.2 MB of libc text, while a hundred copies of hello_dyn share one page-cache-resident copy of libc.so.6.

Note also the paradox in hello_spie: ELF type DYN, yet no interpreter. DYN is about relocatability, not about needing ld.so.

What actually happens at exec

strace counts the loader’s work directly. Complete traces, one run each:

$ strace ./hello_dyn    | grep -c '^[a-z_]*('     ->  35 syscalls
$ strace ./hello_static | grep -c '^[a-z_]*('     ->  18 syscalls
$ strace ./hello_spie   | grep -c '^[a-z_]*('     ->  18 syscalls

The 17-syscall difference is ld.so, itemised. These are the lines present in the dynamic trace and absent from the static one:

access("/etc/ld.so.preload", R_OK)      = -1 ENOENT      # is anything being preloaded?
openat(AT_FDCWD, "/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3
fstat(3, {st_size=133063, ...})         = 0              # the ldconfig cache, 130 KB
mmap(NULL, 133063, PROT_READ, MAP_PRIVATE, 3, 0)         # mapped to look up "libc.so.6"
close(3)
openat(AT_FDCWD, "/lib64/libc.so.6", O_RDONLY|O_CLOEXEC) = 3
read(3, "\177ELF\2\1\1\3"..., 832)      = 832            # read the ELF header
pread64(3, ..., 784, 64)                = 784            # read the program headers
fstat(3, {st_size=2482096, ...})        = 0
pread64(3, ..., 784, 64)                = 784
mmap(NULL, 2067312, PROT_READ|PROT_EXEC, MAP_PRIVATE|MAP_DENYWRITE, 3, 0)      # .text
mmap(0x...8c2000, 495616, PROT_READ, MAP_FIXED|MAP_DENYWRITE, 3, 0x172000)     # .rodata
mmap(0x...93b000, 24576, PROT_READ|PROT_WRITE, MAP_FIXED|MAP_DENYWRITE, 3, 0x1eb000)  # .data
mmap(0x...941000, 31600, PROT_READ|PROT_WRITE, MAP_FIXED|MAP_ANONYMOUS, -1, 0)        # .bss
close(3)
mprotect(0x...93b000, 16384, PROT_READ)                  # RELRO: re-protect the GOT
munmap(0x...949000, 133063)                              # release the ldconfig cache

That is the entire dynamic-linking tax for a one-library program, visible as text: one cache lookup, one file open, two small reads, four mmap calls (one per segment, because the segments have different protections), and an mprotect for RELRO. A program with twenty DT_NEEDED libraries pays the openat/read/pread64/4×mmap/close block twenty times over.

Startup time

Syscall counts are a proxy; wall time is the thing. A small harness forks, execs, and waitpids the target 2,000 times, reporting the minimum (the cleanest estimate of the cost with a warm page cache) and the mean:

$ ./bench ./hello_dyn 2000      n=2000  min=0.172 ms  mean=0.258 ms
$ ./bench ./hello_static 2000   n=2000  min=0.107 ms  mean=0.176 ms
$ ./bench ./hello_spie 2000     n=2000  min=0.113 ms  mean=0.185 ms
# repeat run, same host:
$ ./bench ./hello_dyn 2000      n=2000  min=0.173 ms  mean=0.259 ms
$ ./bench ./hello_static 2000   n=2000  min=0.106 ms  mean=0.188 ms
$ ./bench ./hello_spie 2000     n=2000  min=0.112 ms  mean=0.185 ms

Read those carefully before drawing conclusions. Dynamic linking costs roughly 65 µs more per launch here — about 60% on top of a static launch, but 65 microseconds in absolute terms. For a long-running daemon this is unmeasurable noise. For a shell script that forks 100,000 small tools it is 6.5 seconds, which is why the argument for static coreutils-style binaries has always been about fork-heavy workloads specifically and never about servers. Note also that static-PIE costs only ~6 µs over plain static: the self-relocation stub is genuinely cheap, so there is no good reason to accept a non-PIE static binary for the sake of speed.

LD_DEBUG=statistics shows where the dynamic time goes, and how the proportion scales with the amount of symbol resolution:

$ LD_DEBUG=statistics ./hello_dyn 2>&1 | grep -E 'startup time|relocation'
  total startup time in dynamic loader: 25353 cycles
            time needed for relocation: 492 cycles (1.9%)
                 number of relocations: 95
           time needed to load objects: 7161 cycles (28.2%)
 
$ LD_DEBUG=statistics /usr/bin/ls --version 2>&1 | grep -E 'startup time|relocation'
  total startup time in dynamic loader: 76692 cycles
            time needed for relocation: 26024 cycles (33.9%)
                 number of relocations: 564
 
$ LD_DEBUG=statistics /usr/bin/python3 --version 2>&1 | grep -E 'startup time|relocation'
  total startup time in dynamic loader: 209019 cycles
            time needed for relocation: 157746 cycles (75.4%)
                 number of relocations: 935

For hello, mapping the object dominates (28%) and relocation is negligible (1.9%). By the time you reach python3, relocation is 75% of loader time — 935 symbol lookups, each one a walk through the lookup scope described in Symbol Resolution and Lookup Scope. The scaling variable is symbol count, not library count, which is why Lazy Binding (defer function resolution to first call) and -fvisibility=hidden (keep symbols out of .dynsym altogether) are the two effective mitigations, and why “just link statically for startup speed” is usually solving the wrong half of the problem.

flowchart TB
  SRC["hello.c"]
  SRC --> CC["gcc -c<br/>compile to hello.o<br/>(calls to printf left as<br/>R_X86_64_PLT32 relocations)"]

  CC --> SPLIT{"-static ?"}

  SPLIT -->|"yes"| SL["ld pulls printf, malloc, _start...<br/>OUT of libc.a archive members<br/>and copies them into the output"]
  SL --> SOUT["hello_static — 3,277,096 B<br/>EXEC, no PT_INTERP, no DT_NEEDED<br/>every call resolved to a fixed offset"]
  SOUT --> SEXEC["execve: kernel maps the<br/>segments, jumps straight to _start"]
  SEXEC --> SRUN["18 syscalls · 0.107 ms<br/>no loader in the process at all"]

  SPLIT -->|"no (default)"| DL["ld records NAMES only:<br/>undefined .dynsym entries +<br/>DT_NEEDED libc.so.6 +<br/>PT_INTERP /lib64/ld-linux-x86-64.so.2"]
  DL --> DOUT["hello_dyn — 12,568 B<br/>PLT/GOT slots left empty"]
  DOUT --> DEXEC["execve: kernel sees PT_INTERP,<br/>maps ld.so and jumps to IT, not to _start"]
  DEXEC --> D1["ld.so: read /etc/ld.so.cache,<br/>locate libc.so.6"]
  D1 --> D2["4 mmap calls — one per segment,<br/>each with its own protection"]
  D2 --> D3["build lookup scope,<br/>apply 95 relocations"]
  D3 --> D4["mprotect the GOT (RELRO),<br/>run DT_INIT_ARRAY, jump to _start"]
  D4 --> DRUN["35 syscalls · 0.172 ms<br/>libc text shared with every<br/>other process on the machine"]

What it shows: the two pipelines side by side from one source file, annotated with the measured artefacts at each stage. The insight to take: the divergence happens at a single linker decision — copy the code in, or record its name — and everything downstream (file size, what execve jumps to, how many syscalls, whether the libc pages are shared) follows mechanically from that one choice. Note the asymmetry in where the work lives: static moves it all to build time and pays once, dynamic defers it and pays at every single launch, forever.

The full comparison

DimensionStaticDynamicMeasured / sourced
Binary size (hello world)3,277,096 B12,568 Bmeasured; ratio shrinks sharply for real programs
Startup, fork+exec+exit0.107 ms0.172 msmeasured, min of 2,000 runs; ~65 µs delta
Syscalls at exec1835measured with strace 7.2
Physical memory across N processesN private copies of libc textone shared page-cache copythe central argument of dsohowto
Library CVE responserebuild + redeploy every consumerreplace one .so, restartDrepper: “I consider this alone … to be the killer argument”
ASLR of program textnone (plain static); full with -static-piefullDrepper: “only the stack and heap address can be randomized”
dlopen / pluginsworks, but drags the whole loader back innativemeasured below
NSS (getpwnam, getaddrinfo)files/dns work; everything else needs the matching .so at run timenativemeasured below; glibc 2.34 built nss_files/nss_dns into libc
Locale / iconv / gconvexternal data and converter modules loaded at run timesameDrepper, glibc FAQ
LD_PRELOAD, ltrace, LD_AUDIT, sanitiser interpositiondo not workworkDrepper, no_static_linking
LicensingLGPL requires shipping relinkable objectsdynamic linking satisfies LGPL by constructionDrepper: “no accidental violation of the (L)GPL”
Symbol-version wall (GLIBC_2.x not found)cannot happenthe classic build-on-new/run-on-old failureSymbol Versioning
Build-time availabilitylibc.a not installed by default on Fedora 44always presentmeasured
Debug tooling (gdb on system libs, perf symbolisation)one binary, simpleneeds separate debuginfo per .so—

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) — measured, not assumed

Functions like getpwnam, getaddrinfo, and gethostbyname do not implement a policy; they consult one, written in /etc/nsswitch.conf, and dispatch to pluggable modules (libnss_files.so.2, libnss_dns.so.2, libnss_systemd.so.2, libnss_resolve.so.2, …) that glibc loads with dlopen at run time. The folklore conclusion — “a static glibc binary cannot do name lookups” — is too strong in 2026, and the reason it is too strong matters.

The static link does not fail; it warns, and the warning is the linker telling you the exact terms of the deal:

$ gcc -static -O2 -o nss_static nss.c -L<extracted>/usr/lib64
/usr/bin/ld.bfd: nss.c:(.text.startup+0xa): warning: Using 'getpwnam' in statically
  linked applications requires at runtime the shared libraries from the glibc version
  used for linking
nss.c:(.text.startup+0x55): warning: Using 'getaddrinfo' in statically linked
  applications requires at runtime the shared libraries from the glibc version used
  for linking

But that binary then works perfectly for the common cases, and strace shows why — it opens no .so at all:

$ strace -e trace=openat ./nss_static        # getpwnam("root") + getaddrinfo("localhost")
openat(AT_FDCWD, "/etc/nsswitch.conf", O_RDONLY|O_CLOEXEC) = 3
openat(AT_FDCWD, "/etc/passwd",        O_RDONLY|O_CLOEXEC) = 3
openat(AT_FDCWD, "/etc/host.conf",     O_RDONLY|O_CLOEXEC) = 3
openat(AT_FDCWD, "/etc/resolv.conf",   O_RDONLY|O_CLOEXEC) = 3
openat(AT_FDCWD, "/etc/hosts",         O_RDONLY|O_CLOEXEC) = 3
getpwnam(root) = root
getaddrinfo(localhost) = 0 (ok)

The reason is a change most write-ups have not absorbed: glibc 2.34 (released 2021-08-01) moved the files and dns NSS backends into libc itself — the patch series is dated and quoted below. The shipped libnss_files.so.2 is now a compatibility shell, and the implementation lives in the main library:

$ stat -c%s /lib64/libnss_files.so.2
11136                                              # 11 KB, not a real module
$ readelf --dyn-syms -W /lib64/libnss_files.so.2 | grep -c 'FUNC .*GLOBAL'
0                                                  # it exports NOTHING
 
$ readelf --dyn-syms -W /lib64/libc.so.6 | grep -c '_nss_\(files\|dns\)_'
82                                                 # the real implementations live HERE
$ readelf --dyn-syms -W /lib64/libc.so.6 | grep _nss_files_getpwnam_r
  1649: 0000000000132ea0  186 FUNC GLOBAL DEFAULT 4 _nss_files_getpwnam_r@@GLIBC_PRIVATE

Those 82 GLIBC_PRIVATE symbols get linked into libc.a too, so a static binary carries files and dns internally. That is the good news, and it is genuinely why “static Go/Rust/C tool reads /etc/passwd and /etc/hosts fine” is such a common experience.

Now the bad news, which is worse than the folklore rather than better. Every other NSS service still requires a real dlopen — and on a modern systemd distribution the default nsswitch.conf is full of them. Fedora 44 ships:

passwd: files systemd
hosts:  files myhostname mdns4_minimal [NOTFOUND=return] resolve [!UNAVAIL=return] dns

Resolve a name that /etc/hosts does not answer, and the statically linked binary does this:

$ strace -e trace=openat ./dns_static example.com
openat(AT_FDCWD, "/etc/resolv.conf",  O_RDONLY|O_CLOEXEC) = 3
openat(AT_FDCWD, "/etc/nsswitch.conf",O_RDONLY|O_CLOEXEC) = 3
openat(AT_FDCWD, "/etc/ld.so.cache",  O_RDONLY|O_CLOEXEC) = 3     <-- the loader cache!
openat(AT_FDCWD, "/lib64/libnss_myhostname.so.2",   O_RDONLY|O_CLOEXEC) = 3
openat(AT_FDCWD, "/lib64/libgcc_s.so.1",            O_RDONLY|O_CLOEXEC) = 3
openat(AT_FDCWD, "/lib64/libc.so.6",                O_RDONLY|O_CLOEXEC) = 3   <-- a SECOND libc
openat(AT_FDCWD, "/lib64/ld-linux-x86-64.so.2",     O_RDONLY|O_CLOEXEC) = 3   <-- the loader
openat(AT_FDCWD, "/lib64/libnss_mdns4_minimal.so.2",O_RDONLY|O_CLOEXEC) = 3
openat(AT_FDCWD, "/lib64/libnss_resolve.so.2",      O_RDONLY|O_CLOEXEC) = 3
openat(AT_FDCWD, "/lib64/libm.so.6",                O_RDONLY|O_CLOEXEC) = 3
getaddrinfo(example.com) = 0 (ok)

Read the fourth-from-last line again. The “static” binary has mapped /lib64/libc.so.6 and /lib64/ld-linux-x86-64.so.2 — a complete second copy of the C library plus the dynamic linker it was supposed to have eliminated — because the NSS modules it had to dlopen are themselves dynamically linked against them. Every claim static linking is supposed to buy evaporates at this line: it is not self-contained, it is not free of ld.so, and it is now running two libcs in one address space.

The syscall count makes the absurdity quantitative:

$ strace ./dns_static example.com | grep -c '^[a-z_]*('     ->  159 syscalls
$ strace ./dns_dyn    example.com | grep -c '^[a-z_]*('     ->  151 syscalls

The static binary makes more syscalls than the dynamic one. It paid the full static cost (3 MB of embedded libc, no page sharing) and the full dynamic cost (cache lookup, loader, libc mapping) on top, because it still had to go through the loader at the moment it mattered. This is the practical reason “just link statically” fails, stated as a number rather than a warning.

flowchart TB
  CALL["static binary calls<br/>getaddrinfo(&quot;example.com&quot;)"]
  CALL --> CONF["read /etc/nsswitch.conf<br/>hosts: files myhostname<br/>mdns4_minimal resolve dns"]
  CONF --> SVC{"for each service<br/>in order"}
  SVC -->|"files"| BUILTIN["_nss_files_* is INSIDE libc.a<br/>(merged into libc in glibc 2.34)<br/>-> read /etc/hosts, no dlopen"]
  SVC -->|"dns"| BUILTIN2["_nss_dns_* also inside libc.a<br/>-> talk to resolv.conf servers"]
  SVC -->|"myhostname / mdns4_minimal /<br/>resolve / systemd / sss / ldap"| DLO["NO built-in copy exists<br/>-> __nss_module_load -> dlopen()"]
  BUILTIN --> DONE["answer"]
  BUILTIN2 --> DONE
  DLO --> CACHE["open /etc/ld.so.cache"]
  CACHE --> MAP["map libnss_resolve.so.2"]
  MAP --> DEPS["its DT_NEEDED pull in<br/>ld-linux-x86-64.so.2, libc.so.6,<br/>libgcc_s.so.1, libm.so.6"]
  DEPS --> TWO["process now holds TWO libcs:<br/>the statically linked one<br/>and the freshly mapped .so"]
  TWO --> DONE
  DONE --> COUNT["measured: 159 syscalls static<br/>vs 151 syscalls dynamic"]

What it shows: the branch inside a statically linked glibc program’s name lookup, separating the services that are compiled in from the ones that still require the dynamic loader. The insight to take: whether “static glibc” is self-contained is not a property of your binary at all — it is a property of the deployment host’s /etc/nsswitch.conf, a file chosen by whoever administers the machine, long after you shipped. That is precisely Drepper’s objection: “the selection of the NSS modules is up to the person deploying the code (not the developer), it is not possible to make the assumption that these kind of modules are not used.”

The version-matching hazard the linker warned about is the second half of this. The dlopened module and the statically linked glibc must be the same glibc, because they communicate through GLIBC_PRIVATE interfaces that carry no stability guarantee. Build on Fedora 44’s glibc 2.43, deploy on a host with glibc 2.39, and the dlopen can succeed and then fail on a missing or changed private symbol — a crash inside getaddrinfo with no useful backtrace. The glibc FAQ states the requirement plainly (glibc FAQ), and Drepper adds the part people skip: “it is completely unsupported to dynamically load DSOs this way which are not part of glibc” — which covers nss_ldap, nss_sss, and nss_systemd alike.

Why the merge happened, and the escape hatch that never shipped

The glibc NEWS file carries no bullet naming the NSS merge, but the libc-alpha archive does, and the maintainer’s own framing is more useful than any summary. Florian Weimer posted “[PATCH 0/8] Move nss_files into libc” on 2021-06-07, in the glibc 2.34 development cycle (2.34 was released 2021-08-01), and the cover letter states the motivation and the intended follow-up in five lines (libc-alpha, cover.1623085295.git.fweimer@redhat.com):

I plan to use this to tackle the --enable-static-nss regression in glibc
2.33.
 
I plan to move nss_dns into libc as well, and then we can add a library
with dlopen stubs that blocks all dlopen calls.  If a programmer wants
to build a truly static binary, they can link with -lglibc-nodlopen (or
something like that), and the behavior will be as if glibc was
configured with --enable-static-nss.

Two halves, and only one of them shipped. The nss_files move landed, and nss_dns followed — which is what the 82 _nss_(files|dns)_* GLIBC_PRIVATE symbols in this host’s libc.so.6 are. The second half — -lglibc-nodlopen, the library of dlopen stubs that would let you build a binary guaranteed never to load a module — was never delivered. Searching libc-alpha for nodlopen returns this cover letter and nothing else that implements it, and the shipped glibc-static-2.43-8.fc44 package contains only libc.a, libm.a, libmvec.a, libresolv.a and libBrokenLocale.a, with libc.a still providing __dlopen:

$ find <extracted glibc-static 2.43> -name '*.a' -printf '%f\n' | sort
libBrokenLocale.a
libc.a
libm-2.43.a
libm.a
libmvec.a
libresolv.a                      # no libglibc-nodlopen.a
 
$ nm -A <extracted>/usr/lib64/libc.a 2>/dev/null | grep ' T __dlopen'
libc.a:dlopen.o:0000000000000090 T __dlopen

That unfinished plan is the precise explanation for the half-way state measured above. glibc static linking is no longer as broken as the folklore says — files and dns genuinely work with no external module — but there is still no supported way to say “fail loudly rather than dlopen”. The binary silently reaches for the loader whenever nsswitch.conf names a service it does not carry, which is the failure mode that bites in production because it is invisible on the build host and depends entirely on the deployment host’s configuration. If you need a truly self-contained binary, the answer remains musl or Go, not a glibc build flag — because the glibc build flag was designed, announced, and then not written.

dlopen itself, locales, and iconv

The same pattern repeats wherever glibc defers a decision to a file on disk. A statically linked binary that calls dlopen directly (to load a plugin) drags the entire dynamic-loading machinery back in, with the same version-matching hazard. Character-set conversion goes through gconv modules under /usr/lib64/gconv/, loaded at run time, so iconv_open("UTF-8", "SHIFT_JIS") in a static binary needs those files present. Locale data lives in /usr/lib/locale/locale-archive and is mmaped, not compiled in, so setlocale(LC_ALL, "de_DE.UTF-8") depends on the host’s installed locales. Drepper’s summary is the accurate one: “all kinds of features in the libc (locale (through iconv), NSS, IDN, …) require dynamic linking to load the appropriate external code. We have very limited support for doing this in statically linked code” (per Static Linking Considered Harmful). A glibc static binary is hermetic exactly to the extent that it avoids these four subsystems — which a network service that resolves hostnames and formats dates does not.

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.

Resolved: Fedora’s gcc does not default to PIE — the RPM build does

A previous revision of this note flagged as uncertain whether “Fedora defaults to PIE” is a compiler property or a packaging one. It is a packaging one, and the distinction matters because it means your locally built binaries are not hardened the way the distro’s are. Three checks settle it on Fedora 44 / GCC 16.2.1 20260819:

$ gcc -v 2>&1 | grep -o 'enable-default-pie'
(no output — --enable-default-pie is absent from the configure line)
 
$ gcc -O2 -o hello_dyn hello.c && readelf -h hello_dyn | grep Type:
  Type:  EXEC (Executable file)                     <- NOT position-independent
 
$ readelf -h /usr/bin/ls | grep Type:
  Type:  DYN (Position-Independent Executable file) <- the RPM-built one IS

The hardening comes from two GCC spec fragments that the RPM macros inject at build time and nothing else does (/usr/lib/rpm/redhat/redhat-hardened-cc1 and redhat-hardened-ld):

/usr/lib/rpm/redhat/redhat-hardened-cc1:
*cc1_options:
+ %{!r:%{!fpie:%{!fPIE:%{!fpic:%{!fPIC:%{!fno-pic:-fPIE}}}}}}
 
/usr/lib/rpm/redhat/redhat-hardened-ld:
*self_spec:
+ %{!static:%{!shared:%{!r:-pie}}}

Read the spec syntax: %{!fpie:...} means “unless -fpie was already given”, so the fragments add -fPIE at compile and -pie at link only if the package did not already choose for itself, and the link fragment explicitly exempts -static and -shared. /usr/bin/ls accordingly comes out DYN with both GNU_RELRO and BIND_NOW (full RELRO); the locally compiled hello_dyn comes out EXEC with partial RELRO and no BIND_NOW. The lesson generalises past Fedora: “is this distribution PIE by default?” is almost always a question about its RPM/dpkg build macros, not about its compiler, and benchmarking or security-auditing a hand-built binary tells you nothing about the shipped ones.

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.

The search-path knobs a static binary does not have

Everything a static binary is immune to, a dynamic one must configure, and the ordering of the search-path mechanisms is a recurring source of surprise. The full path search is the subject of Library Search Path and ldconfig; what belongs here is the precedence rule, because it is the part people get wrong and it has a measurable answer.

MechanismDynamic tagSet byPosition in search orderOverridable at run time?
DT_RPATH0x0f-Wl,--disable-new-dtags,-rpath,DIRfirst — before LD_LIBRARY_PATHno — baked in, cannot be overridden
LD_LIBRARY_PATH—environmentafter DT_RPATH, before DT_RUNPATHyes, by whoever runs the program
DT_RUNPATH0x1d-Wl,--enable-new-dtags,-rpath,DIRafter LD_LIBRARY_PATHyes
ld.so.cache—ldconfig reading /etc/ld.so.confafter all of the abovevia ldconfig (root)
Default dirs (/lib64, /usr/lib64)—built inlastno

The two -rpath rows differ only in a linker flag, and the behavioural difference is total. Two otherwise identical programs, one built each way, with an LD_LIBRARY_PATH pointing at a different copy of the same library:

$ readelf -d p_rpath   | grep -E 'RPATH|RUNPATH'
 0x...0f (RPATH)   Library rpath:   [$ORIGIN/rpathdir]
$ readelf -d p_runpath | grep -E 'RPATH|RUNPATH'
 0x...1d (RUNPATH) Library runpath: [$ORIGIN/rpathdir]
 
$ LD_LIBRARY_PATH=$PWD/ldpathdir ./p_rpath      ->  say: RPATHDIR    # rpath WON
$ LD_LIBRARY_PATH=$PWD/ldpathdir ./p_runpath    ->  say: LDPATHDIR   # env WON

DT_RPATH is the older, un-overridable tag; DT_RUNPATH was introduced so that LD_LIBRARY_PATH could still win, which is what makes debugging and library-shimming possible. GNU ld also honours the precedence at link time: “The DT_RPATH entries are ignored if DT_RUNPATH entries exist” (binutils 2.46 ld documentation).

Uncertain — but measured here

The upstream binutils 2.46 documentation states: “By default, the new dynamic tags are not created.” On this host that is false in practice — gcc -Wl,-rpath,DIR with no dtags flag produced DT_RUNPATH, and gcc -### confirms GCC passes no --enable-new-dtags of its own, so Fedora’s binutils 2.46.1-1.fc44 must be configured with new dtags on. Treat “which tag do I get by default?” as a per-distribution question and check with readelf -d rather than trusting either the manual or this note. Debian and Ubuntu have defaulted to new dtags for years; the upstream default appears to lag the distributions. To resolve properly: read the --enable-new-dtags configure default in Fedora’s binutils.spec. uncertain

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.

Why Distributions Refuse Static Linking — The Patching Argument

Of all the arguments in this note, one decides distribution policy, and it is not size or startup time. Drepper puts it first and does not hedge: fixes “have to be applied to only one place: the new DSO(s). If various applications are linked statically, all of them would have to be relinked. By the time the problem is discovered the sysadmin usually forgot which apps are built with the problematic library. I consider this alone (together with the next one) to be the killer arguments” (per Static Linking Considered Harmful).

The argument is about fan-out, and fan-out on a real system is enormous. Counting how many executables in /usr/bin on this Fedora 44 install name each library in their DT_NEEDED:

$ for f in /usr/bin/*; do readelf -d "$f" 2>/dev/null | grep -q '\[libc.so.6\]' && echo x; done | wc -l
Library/usr/bin executables that link itUnder dynamic linking, a CVE meansUnder static linking, it would mean
libc.so.62,867 of 3,491replace 1 file (glibc package), restart servicesrebuild and redeploy 2,867 binaries
libcrypto.so.3210replace 1 file (openssl-libs)rebuild 210 binaries
libz.so.1119replace 1 file (zlib)rebuild 119 binaries
libssl.so.317replace 1 filerebuild 17 binaries

That is only the direct count — binaries naming the library in their own dynamic section. Transitively, essentially everything on the machine depends on libc. A glibc CVE on a dynamically linked system is one dnf update glibc plus a restart of running services; on a statically linked equivalent it is a full-distribution rebuild, and — Drepper’s real point — the hard part is not the rebuilding, it is knowing what to rebuild. A shared object announces its own presence: ldd, readelf -d, and the package manager’s dependency graph all answer “who uses this?” precisely. A statically linked copy of zlib inside a binary leaves no such record. It is invisible to ldd, invisible to dnf, and invisible to a CVE scanner that works by matching installed package versions.

That invisibility is the same problem the software-supply-chain world rediscovered under a new name. The entire reason SBOM tooling exists — and the reason go version -m, cargo auditable, and Debian’s dh_shlibdeps all try to embed a dependency manifest into the artefact — is to reconstruct, by other means, the inventory that dynamic linking gets for free. This is the honest framing of the trade-off: static linking does not remove the patching obligation, it removes the bookkeeping that makes the obligation tractable, and shifts the cost onto a build-and-inventory pipeline you must now operate yourself.

The second of Drepper’s “killer arguments” is address-space layout randomisation: “With statically linked applications, only the stack and heap address can be randomized. All text has a fixed address in all invocations … Fixed addresses (or even only fixed offsets) are the dreams of attackers.” Here his 2006-era text has been overtaken by events and should be read with a date attached — -static-pie (glibc 2.27, 2018) closes exactly this gap, and the measurement above shows it costs about 6 µs of extra startup and 67 KB of extra size over plain static. The claim that “it is not possible in general to generate PIEs with static linking” was true when written and is not true now. His remaining objections — NSS, locale, iconv, the loss of LD_PRELOAD/ltrace/LD_AUDIT tooling, and the (L)GPL relinking obligation — have not been closed by anything since, and the NSS one was measured above to be worse in practice than he described.

The memory argument deserves one concrete figure rather than a hand-wave. libc.so.6 on this host is 2,482,096 bytes, of which the strace output showed 2,067,312 bytes mapped PROT_READ|PROT_EXEC — the shareable text. With 2,867 executables linking it, if every one of them carried a private copy instead of sharing the page-cache-resident one, the resident text alone would approach 5.9 GB across a machine running them all. In practice it is one copy. That ratio, not the per-binary file size, is what the “dynamic linking saves memory” claim actually refers to.

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.

Stated as a decision table, with the disqualifying condition for each choice made explicit:

SituationChooseWhy, and what disqualifies the alternative
Package installed by a distro package managerdynamic, glibcpage-cache sharing across hundreds of processes; one-file CVE response. Static disqualifies itself on the patching fan-out measured above.
Single-file CLI tool distributed outside a package managerstatic, musl or Gono libc version wall, no ld.so. Static glibc is disqualified by the NSS trap if the tool resolves names.
FROM scratch / distroless container imagestatic, musl / Go / Rust-muslnothing else is in the image to link against. glibc static is disqualified for the same NSS reason, plus locale and gconv data.
Rescue, initramfs, or recovery toolingstatic (or static-PIE)must run when /usr is unmounted or libc.so.6 is the broken thing. This is static linking’s least arguable use case.
Security-sensitive static binary-static-pieplain static forfeits text ASLR; static-PIE costs ~6 µs and ~67 KB (measured) to get it back.
Program that dlopens pluginsdynamica static binary that dlopens re-imports the entire loader and a second libc — measured above.
Program that calls getaddrinfo/getpwnam on an unknown hostdynamic, or static musl/Go with a built-in resolverthe deployment host’s nsswitch.conf is outside your control.
Shipping a proprietary binary linked against an LGPL librarydynamicdynamic linking satisfies the LGPL relinking obligation by construction; static requires shipping relinkable objects.
Fork-heavy workload (shell scripts spawning thousands of tools)static, if the tools qualify above~65 µs × N launches is the only case where the startup delta is material.
Long-running server or daemondynamicthe startup delta is paid once and is ~65 µs; the memory and patching wins recur forever.

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.

Three further patterns are worth recording because they are where teams actually get hurt.

The partially-static binary is the worst of both worlds, and it is the default outcome of -static-libgcc -static-libstdc++. Teams reaching for “fewer dependencies” often statically link the C++ runtime while leaving glibc dynamic. This is a supported and common configuration, but it inherits the patching problem for libstdc++ (a bug there now requires rebuilding every consumer) without buying self-containment, since the binary still needs a compatible libc.so.6 at the right version. The version-wall failure — version 'GLIBC_2.38' not found — is unaffected, because it comes from glibc symbol versioning (Symbol Versioning), not from libstdc++. If the motivation is “run on older systems”, the mechanism that actually works is building against the oldest glibc you intend to support, not static-linking the parts that were never the problem.

ldd is not a safe inspection tool for untrusted binaries. On glibc, ldd works by running the dynamic linker against the file with a special environment variable, which on some configurations means executing code from the target. The man page warns about this directly. The safe equivalents are readelf -d (dynamic tags, no execution) and objdump -p, both of which read the file as data. This matters precisely in the forensic situation where you care most: inspecting a binary you did not build.

Statically linked language runtimes made the CVE-inventory problem mainstream, and the ecosystem responded by reinventing ldd. Go and Rust ship static binaries by default, which is exactly the configuration Drepper argued against — and the consequence arrived on schedule: a vulnerability in a widely vendored Go module cannot be found by asking the package manager what is installed. The response was not to abandon static linking but to embed the manifest: go version -m ./binary prints the full module graph baked into a Go binary, cargo auditable does the equivalent for Rust, and syft/grype-class scanners read both. That is the honest end state of this debate as of 2026 — static linking won the distribution format argument for applications, and then had to rebuild, in metadata, the dependency inventory that dynamic linking had provided structurally. Anyone shipping static binaries at scale needs that inventory and a rebuild-and-redeploy pipeline in place before the CVE, not after.

Uncertain

Verify: the claim that libstdc++’s static-linking trade-offs are as described for GCC 16.2.1 specifically, and that -static-libstdc++ remains the recommended mechanism rather than a newer one. Reason: this paragraph rests on the general ELF mechanics established elsewhere in this note plus long-standing GCC behaviour, not on a GCC 16 documentation page fetched during this pass. To resolve: read the -static-libstdc++ entry in the GCC 16 manual and the libstdc++ ABI policy document. uncertain

See Also