Static Linking
Copy the library into the binary. One file to deploy, no runtime dependency, no version skew — paid for in binary size and in having to relink and redeploy for every library fix, including a security fix.
What do I actually get by linking statically, and what am I giving up?
The program as a single self-contained image: every function it will ever call is present in the file, at an address fixed at link time, with no unresolved external references. That representation exists to answer "what does this program need in order to run" with "nothing" — which is the property the whole technique is bought for.
A static link is complete only when every undefined reference has been satisfied from the objects and archives supplied. Archive members are pulled in on demand — a member is included only if it defines a symbol that is undefined at the moment the archive is scanned — which makes the result dependent on command-line order and means the same inputs in a different order can produce a different program or fail entirely. Statically linking a library is legal only under its licence: the LGPL in particular requires that the user be able to relink against a modified version, which a plain static binary does not permit without additional artifacts.
Key points
- An archive is a container of object files searched on demand; only members satisfying a currently-undefined symbol are included.
- That on-demand rule is single-pass, which is why an archive must appear after the objects that need it and why circular archive dependencies need
--start-group. - Static linking buys no runtime dependencies, faster start-up, direct calls, cross-library inlining and byte-for-byte determinism.
- The size cost is real and usually overstated; the upgrade cost — rebuild and redeploy everything for a library fix — is the one that decides the argument.
- Static linking rules out plugins and
dlopenentirely, which is an architectural constraint rather than a performance one. - glibc static linking is partial because NSS loads shared objects at runtime; musl is the usual answer for genuinely static Linux binaries.
What an archive is, and why order matters
.lib files. The rule is a design choice from an era of very limited memory, retained for compatibility.A static library — .a on Unix, .lib on Windows — is not a library in any meaningful sense. It is an archive: a container holding object files, with an index mapping symbol names to the member that defines them. ar creates it and nm reads it, and it has no code of its own.
The linker treats an archive differently from an object file, and this is the source of most static-linking confusion. An object file on the command line is always included. An archive is *searched*: when the linker reaches it, it looks for members defining symbols that are currently undefined, includes those, and moves on. Members defining nothing currently needed are skipped, permanently.
"Permanently" is the operative word. If an archive appears before the object that needs it, nothing is pending when it is scanned, so nothing is taken, and the later reference has no definition. This is why gcc -lm main.o fails where gcc main.o -lm succeeds, and it is a rule that surprises everyone exactly once. Circular dependencies between archives need --start-group/--end-group or a repeated mention, because a single pass cannot resolve them.
This also explains why static linking is granular. Only the members that were actually needed are included, so linking against a large archive does not embed the whole thing — a fact that makes the size argument against static linking much weaker than it first appears.
1$ ar t libutil.a # the archive holds three objects2hash.o3json.o4zip.o5 6$ gcc main.o -lutil -o app # main.o references only hash_of()7$ nm app | grep -c json_ # json.o was never pulled in809 10$ gcc -lutil main.o -o app # archive scanned first: nothing pending11main.o: undefined reference to 'hash_of'12 ^ the archive was searched when the reference did not exist yet,13 contributed nothing, and is not searched again.The second command is the failure everybody hits. It is not that the library is missing — it is that it was consulted at a moment when no question had been asked. Modern linkers can be told to rescan (--start-group), and some drivers reorder for you, but the underlying single-pass rule is why the convention "objects first, then libraries" exists.
The case for static
A statically linked binary has no runtime dependencies to satisfy. It runs on a machine with no matching libc version, in a container built FROM scratch, on a rescue system, or on a machine you have never seen. There is no libfoo.so.6: cannot open shared object file and no GLIBC_2.34 not found, because there is nothing to find.
It is also faster to start and marginally faster to run. No symbol resolution at load, no relocations for the loader to apply, no PLT indirection on cross-module calls, and — the more significant effect — every call is a direct call the optimizer can see through, so cross-library inlining becomes possible and [[link-time-optimization]] can operate across what were library boundaries. For short-lived processes the start-up difference alone can dominate.
And it is deterministic. The program you tested is the program that runs, byte for byte, with no possibility that the deployment environment supplies a different implementation of anything. That is a substantial reliability argument and it is why Go chose static linking by default and why a large fraction of container images ship static binaries.
| Axis | Static | Dynamic |
|---|---|---|
| Deployment | One file, no dependencies to install or match | The right libraries must be present at the right versions |
| Disk, one program | Larger — each program carries its own copy of what it uses | Smaller — one copy on disk serves every program |
| Memory, many programs | Each process has its own copy of the library code | One physical copy of .text is shared across every process |
| Start-uptypical | Faster: nothing to resolve or relocate | Slower: map libraries, apply relocations, bind symbols |
| Steady-state speedtypical | Direct calls; cross-library inlining and LTO possible | PLT indirection per cross-module call; no inlining across the boundary |
| Security fix in a library | Rebuild and redeploy every program that uses it | Replace one file; every program picks it up on next start |
| Version skew | Impossible — the version is in the binary | A permanent hazard, managed with sonames and version tags |
| Plugins | Not possible — nothing can be loaded later | The entire point of the mechanism |
The bill, and the part of it that matters
CGO_ENABLED=0 Go binaries are genuinely dependency-free.The size cost is real and usually overstated. Because archives are extracted on demand, a static binary contains what it uses rather than the whole library, and --gc-sections prunes further. A static Go binary is a few megabytes; a statically linked C program using a handful of libc functions is often smaller than the dynamic version once you count the shared objects it would otherwise require. The size argument is much weaker than folklore holds.
The cost that actually matters is upgrade coupling. When a vulnerability is found in a compression library, a dynamically linked system replaces one file and every program on it is fixed at next start. A statically linked fleet must rebuild and redeploy every binary that contains a copy — which requires knowing which binaries those are, which requires a bill of materials, which many organisations do not have. This is not a hypothetical: it is the operational shape of every widely-used-library vulnerability, and it is the single strongest argument distributions make against static linking.
There are two further costs worth naming. Plugins are impossible — a statically linked program cannot load code later, which rules out an entire architectural style. And on glibc specifically, static linking is only partly supported: getaddrinfo and NSS load shared modules at runtime regardless, so a "static" glibc binary can still fail on a machine with different NSS libraries. musl has no such caveat, which is why static Linux binaries are usually built against musl.
- The size penalty is smaller than folklore suggests, because archives are extracted per-member and unused sections are collected.
- The upgrade penalty is the real one: a library fix requires rebuilding and redeploying every binary that embedded it.
- You need a bill of materials to know which binaries those are, which is a supply-chain capability rather than a build flag.
- No plugins, no
dlopen, no runtime extension of any kind. - glibc static linking is partial: NSS and
getaddrinfostill load shared objects at runtime. musl does not have this problem. - Licences matter: LGPL libraries require that users can relink, which a plain static binary does not provide.
Where the argument is currently settled
The practical consensus has moved and it is worth stating rather than relitigating. Operating system components link dynamically, because a distribution needs to patch one file and fix everything, and because the OS is the one place where many processes genuinely run the same code and the memory sharing is worth real money.
Applications deployed as containers increasingly link statically, because the container already froze the dependency set — the sharing argument does not apply when every container has its own filesystem, and the patching argument is answered by rebuilding the image, which the pipeline does anyway. Go and Rust default to statically linking everything except libc for exactly this reason.
The interesting middle case is partial static linking: dynamic libc, static everything else. It keeps the one library the platform genuinely owns and patches, and removes version skew for the dependencies you control. -static-libstdc++ and -static-libgcc are the common instances, and Rust's default on Linux is precisely this shape.
How it works
The steps, in the order the compiler takes them.
- The compiler produces object files;
arbundles some of them into an archive with a symbol index. - The linker processes the command line left to right, always including object files and searching archives for members that satisfy currently-undefined symbols.
- Each included member may introduce further undefined symbols, which subsequent inputs — or a rescan within a group — must satisfy.
- When resolution completes, the included sections are merged, laid out and relocated exactly as any other link.
- Unreferenced sections may be garbage-collected if the inputs were compiled with per-function and per-data sections.
- The result contains no undefined dynamic symbols and, in a fully static link, requires no interpreter, so the kernel can map and enter it directly with no dynamic loader involved.
How it breaks
What the engineer observes when it goes wrong — not what goes wrong internally.
- A link fails with an undefined reference to a function that is definitely in the library, because
-lfoocame before the object that needed it. - Two archives depend on each other and a single pass cannot satisfy both, so the link fails until they are wrapped in
--start-groupor one is repeated. - A "static" glibc binary works on the build machine and fails to resolve hostnames on the target, because NSS still loads shared objects and the target has different ones.
- A vulnerability is announced in a widely used library and nobody can enumerate which deployed binaries contain a copy, so the remediation takes weeks instead of a package update.
- The binary is much larger than expected because debug information was retained and no section garbage collection was enabled — a size problem attributed to static linking that is actually a build-flag problem.
- A program is shipped statically linked against an LGPL library, and the licence obligation to permit relinking is not met by anything in the release.
When it helps
- Deploying to environments you do not control or cannot inspect: rescue images, embedded systems,
FROM scratchcontainers, single-binary CLI tools. - Short-lived processes where load-time symbol resolution is a measurable fraction of total runtime.
- Anywhere reproducibility matters most: the binary you tested is exactly the binary that runs, with no environmental substitution possible.
When it hurts
- System-wide deployment of many programs sharing large libraries, where dynamic linking's shared code pages are a genuine memory saving and a single patched file fixes everything at once.
- Anything requiring plugins,
dlopen, or runtime extension, which static linking makes impossible rather than merely awkward.
What it costs
Every one of these is paid by something.
- Static linking buys deployment simplicity, start-up speed, direct calls and determinism; it costs binary size, per-process memory that would otherwise be shared, and the ability to load code at runtime.
- It buys freedom from version skew and costs the ability to fix a library once: every embedded copy must be rebuilt and redeployed, which converts a package update into a fleet-wide release.
- On-demand archive extraction buys binaries containing only what they use and costs an order-dependent, single-pass link that fails confusingly when the command line is arranged wrongly.
- Static libc buys a truly dependency-free binary and costs, on glibc, correctness in exactly the areas that use runtime plugins — which is why the choice usually comes bundled with a choice of libc.
What else you could do
What a different compiler or language does instead, and when that is better.
- Dynamic linking, which inverts every entry in the table: shared memory, patchable in place, and version skew as a permanent operational concern. See
[[dynamic-linking]]. - Partial static linking — dynamic libc, static everything else — which is Rust's Linux default and the usual pragmatic answer: keep the library the platform patches, freeze the ones you own.
- Containers, which achieve the deployment property by shipping the whole filesystem rather than by changing the link. The dependency set is frozen either way; the difference is artifact size and how patching is organised.
- Bundling shared objects with the application and setting an
RPATHto find them, which is what many desktop applications do: dynamic linking mechanics with static-linking version control. See[[symbol-resolution-order]].
See it for yourself
The flag, dump or tool that shows you this directly.
- Confirm it is static:
file appsays "statically linked", andldd appreports "not a dynamic executable". - See what came from where:
-Wl,-Map=app.mapproduces a link map naming every archive member pulled in and the symbol that caused it. - See what an archive contains:
ar t lib.alists members,nm lib.ashows what each defines. - Debug an order problem:
-Wl,-y,symbolreports every input that references or defines a symbol, which shows immediately that the archive was scanned before the reference existed. - Compare sizes honestly: build both ways with the same optimization and stripping flags, and compare the static binary against the dynamic one plus the shared objects it requires.
Plausible wrong readings
Stated the way a confident engineer states them.
- "Static linking includes the whole library." It includes the archive members that were needed. Unused members are never extracted, and unreferenced sections can be collected on top of that.
- "Static binaries are always bigger." Compared against a dynamic binary alone, yes. Compared against the dynamic binary plus the shared objects it needs, often not.
- "Static linking is a security improvement because there is nothing to hijack." It removes
LD_PRELOAD-style interposition and adds the obligation to rebuild everything for every library CVE. That trade favours dynamic linking in most fleets. - "A statically linked glibc program has no runtime dependencies." NSS and
getaddrinfostill load shared objects. Only musl-based or pure-Go binaries are genuinely dependency-free.
Misconceptions
The claim, and what is actually true.
Go deeper
The same idea at increasing depth. Stop wherever it stops being useful.
overview
Static linking copies the parts of a library your program uses into the program itself. The result is one file that runs anywhere, with nothing to install alongside it. The catch is that if the library later needs a fix, your program still contains the old copy, and the only way to fix it is to rebuild and redeploy.
practical
Put object files before archives on the command line, and wrap mutually dependent archives in --start-group. Check the result with file and ldd. If you want a genuinely dependency-free Linux binary, build against musl or use Go with cgo disabled — a static glibc binary still loads shared objects for name resolution. And before shipping statically, make sure something can tell you which binaries contain which library versions, because that list is what a CVE announcement will require.
advanced
The honest framing is that static and dynamic linking place the same decision at different times, and the argument is really about who is responsible for a library and how many processes share it. A distribution owns thousands of packages and cannot rebuild them all for every CVE, so it needs the one-file-fixes-everything property and pays with version skew and load-time cost. A single service in a container owns its whole dependency graph and rebuilds it on every deploy anyway, so it takes the determinism and pays nothing it was not already paying. The reason the industry appeared to reverse course on this is not that anyone changed their mind about the mechanics; it is that containerisation changed which of the two situations most deployments are in.
How much this depends on
Nothing in this domain is true of every compiler. These say how much.
getaddrinfo loading shared modules regardless — is a documented property of glibc specifically. musl has no such behavior, and Go's pure-Go resolver avoids it entirely, which is why "static Linux binary" in practice means "musl or Go".If you were asked this in an interview
- Why does
gcc -lm main.ofail wheregcc main.o -lmsucceeds? - A CVE is announced in a compression library. Walk through what happens on a statically linked fleet and on a dynamically linked one.
- When is static linking the better choice, and what does it make impossible?
Connections
- DevOps / Production Engineering — Software bills of materials and fleet-wide dependency remediationStatic linking converts "patch one file" into "find and rebuild every binary containing a copy", which is only tractable with an inventory. Producing and querying that inventory is a supply-chain and release-engineering capability, and it is the real cost of the choice made here.