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.
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 syscallsThe 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 cacheThat 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 msRead 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: 935For 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
| Dimension | Static | Dynamic | Measured / sourced |
|---|---|---|---|
| Binary size (hello world) | 3,277,096 B | 12,568 B | measured; ratio shrinks sharply for real programs |
Startup, fork+exec+exit | 0.107 ms | 0.172 ms | measured, min of 2,000 runs; ~65 µs delta |
| Syscalls at exec | 18 | 35 | measured with strace 7.2 |
| Physical memory across N processes | N private copies of libc text | one shared page-cache copy | the central argument of dsohowto |
| Library CVE response | rebuild + redeploy every consumer | replace one .so, restart | Drepper: “I consider this alone … to be the killer argument” |
| ASLR of program text | none (plain static); full with -static-pie | full | Drepper: “only the stack and heap address can be randomized” |
dlopen / plugins | works, but drags the whole loader back in | native | measured below |
NSS (getpwnam, getaddrinfo) | files/dns work; everything else needs the matching .so at run time | native | measured below; glibc 2.34 built nss_files/nss_dns into libc |
Locale / iconv / gconv | external data and converter modules loaded at run time | same | Drepper, glibc FAQ |
LD_PRELOAD, ltrace, LD_AUDIT, sanitiser interposition | do not work | work | Drepper, no_static_linking |
| Licensing | LGPL requires shipping relinkable objects | dynamic linking satisfies LGPL by construction | Drepper: “no accidental violation of the (L)GPL” |
Symbol-version wall (GLIBC_2.x not found) | cannot happen | the classic build-on-new/run-on-old failure | Symbol Versioning |
| Build-time availability | libc.a not installed by default on Fedora 44 | always present | measured |
Debug tooling (gdb on system libs, perf symbolisation) | one binary, simple | needs 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 linkingBut 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_PRIVATEThose 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] dnsResolve 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 syscallsThe 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("example.com")"] 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 __dlopenThat 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 ISThe 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_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.
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.
| Mechanism | Dynamic tag | Set by | Position in search order | Overridable at run time? |
|---|---|---|---|---|
DT_RPATH | 0x0f | -Wl,--disable-new-dtags,-rpath,DIR | first — before LD_LIBRARY_PATH | no — baked in, cannot be overridden |
LD_LIBRARY_PATH | — | environment | after DT_RPATH, before DT_RUNPATH | yes, by whoever runs the program |
DT_RUNPATH | 0x1d | -Wl,--enable-new-dtags,-rpath,DIR | after LD_LIBRARY_PATH | yes |
ld.so.cache | — | ldconfig reading /etc/ld.so.conf | after all of the above | via ldconfig (root) |
Default dirs (/lib64, /usr/lib64) | — | built in | last | no |
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 WONDT_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,DIRwith no dtags flag producedDT_RUNPATH, andgcc -###confirms GCC passes no--enable-new-dtagsof 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 withreadelf -drather 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-dtagsconfigure default in Fedora’sbinutils.spec. uncertain
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.
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 it | Under dynamic linking, a CVE means | Under static linking, it would mean |
|---|---|---|---|
libc.so.6 | 2,867 of 3,491 | replace 1 file (glibc package), restart services | rebuild and redeploy 2,867 binaries |
libcrypto.so.3 | 210 | replace 1 file (openssl-libs) | rebuild 210 binaries |
libz.so.1 | 119 | replace 1 file (zlib) | rebuild 119 binaries |
libssl.so.3 | 17 | replace 1 file | rebuild 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:
| Situation | Choose | Why, and what disqualifies the alternative |
|---|---|---|
| Package installed by a distro package manager | dynamic, glibc | page-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 manager | static, musl or Go | no libc version wall, no ld.so. Static glibc is disqualified by the NSS trap if the tool resolves names. |
FROM scratch / distroless container image | static, musl / Go / Rust-musl | nothing 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 tooling | static (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-pie | plain static forfeits text ASLR; static-PIE costs ~6 µs and ~67 KB (measured) to get it back. |
Program that dlopens plugins | dynamic | a static binary that dlopens re-imports the entire loader and a second libc — measured above. |
Program that calls getaddrinfo/getpwnam on an unknown host | dynamic, or static musl/Go with a built-in resolver | the deployment host’s nsswitch.conf is outside your control. |
| Shipping a proprietary binary linked against an LGPL library | dynamic | dynamic 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 daemon | dynamic | the 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
- 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
- Symbol Resolution and Lookup Scope — what the loader does with each
DT_NEEDEDname once the libraries are mapped; the 935-relocationpython3figure above is 935 trips through that algorithm - Symbol Versioning — why a binary built on glibc 2.43 refuses to start on glibc 2.39
- Lazy Binding — the deferral that keeps dynamic startup cost down, and what
LD_BIND_NOWtrades away - RELRO Relocation Read-Only — the
mprotectvisible in the dynamicstraceabove - Thread-Local Storage — TLS models differ between static and dynamic builds (initial-exec is available statically, global-dynamic is not needed)
- Symbol Interposition and LD_PRELOAD — the tooling ecosystem static linking gives up
- Software Bill of Materials — the metadata layer static binaries need to replace the dependency inventory
lddprovides for free - Linux Userspace Runtime MOC — parent map
- Compilers and Toolchains MOC — parent map (linker and ABI section)