ABI Stability
Why adding one private field to a class in a shared library breaks every program already compiled against it, what pimpl and reserved padding actually buy, and why Rust deliberately refuses to have a stable ABI at all.
Why can I not add a field to a class in my shared library without rebuilding everything that uses it?
A published type as a frozen byte layout, and a published function as a frozen symbol with a frozen convention. Once a library ships, its callers exist as machine code containing that layout as immediate operands; the representation the library must respect is therefore not its own header but the set of assumptions already compiled into binaries it will never see.
A library revision preserves binary compatibility only if every fact an existing caller's machine code depends on is still true: sizes and alignments of types the caller allocates or embeds, offsets of fields the caller reads, positions of virtual functions the caller calls, spellings and conventions of symbols the caller references, and the bodies of any inline function or template the caller has already copied into itself. Adding entirely new symbols is always safe; changing an existing one almost never is. Note that this is a property of the *pair* — an assumption only matters if some caller took it — which is why hiding a type behind an opaque pointer makes changes to it legal that would otherwise not be.
Key points
- A caller compiles sizes, offsets and vtable slots into its own machine code, so any change to those in the library is invisible to the build and fatal at runtime.
- Adding new symbols is always compatible; changing existing types, offsets, vtable positions or inline bodies almost never is.
- Pimpl and opaque handles work by ensuring the caller never learns a layout, which makes changing the layout legal.
- Inlining and binary compatibility are opposites: anything a caller can inline is a body the caller has copied and is stuck with.
- Symbol versioning converts an invisible incompatibility into a name difference the linker and loader can act on, which is what lets one glibc serve twenty years of binaries.
- Rust declines a stable ABI so it can reorder fields and change representations freely, and offers
#[repr(C)]plusextern "C"as the explicit opt-in when a frozen boundary is genuinely needed.
One field, every caller
Take a shared library that publishes a class with two int members and a method. A program compiled against it contains three baked-in facts: the class is eight bytes, the second member is at offset four, and the method lives at a particular mangled symbol. If the program ever allocates one of these objects — on its stack, inside another object, in an array — it also contains the size as a literal in a stack adjustment or an allocation call.
Now add one private int. The library rebuilds cleanly and every one of the caller's three facts is now false in the same silent way [[abi]] describes. The stack allocation is four bytes short, so the library's constructor writes past the caller's object into whatever is next. The library reads its new field where the caller has something else. Nothing in the build or the link notices.
It gets worse with virtual functions. A virtual call is compiled as "load the vtable pointer, load slot 2, call it". Insert a virtual function before an existing one and slot 2 now holds something else, so the caller performs a virtual call to the wrong method with the arguments intended for another — which sometimes runs, which is the worst possible outcome.
| Change | Recompile needed? | What an un-recompiled caller does |
|---|---|---|
| Add a new non-virtual member functiontarget | No | Nothing — it is a new symbol nobody references |
| Add a virtual function at the end of the classtarget | Usually not, if no derived class exists outside | Nothing, unless a caller derives from the class and appends its own slots |
| Add a virtual function anywhere elsetarget | Yes | Calls the wrong method through a shifted vtable slot |
| Add a data membertarget | Yes | Allocates the object too small; reads later fields at wrong offsets |
| Reorder existing memberstarget | Yes | Reads each field's neighbour, silently |
| Change a parameter type | Yes in C++, and it is an honest link error | C++: undefined reference. C: links and passes the wrong-width value |
| Change the body of an inline function or templatetypical | Yes | Keeps running the old body it copied at its own compile time |
| Change a default argument valuespec | Yes | Keeps passing the old default, which the caller compiled in |
Pimpl: hide the layout and there is nothing to break
The pointer-to-implementation idiom is the direct application of the legality condition above. If the published class contains exactly one member — a pointer to an undeclared type — then its size is one pointer forever, no caller can ever compute an offset into the real state, and every field of the real state can be added, removed and reordered freely.
The price is exactly what it sounds like. Every access to a member is now a pointer indirection. Every object requires a second heap allocation, so construction is slower and locality is worse. And no member function can be inlined into a caller, because its body must live in the library where it can see the implementation type — which is the same reason it is safe to change.
That last trade is worth stating clearly because it is the general form: inlining and ABI stability are opposites. Anything a caller can inline is a body the caller has copied and is now stuck with. C++ modules, header-only libraries, templates and constexpr all move code across the boundary for performance and all destroy binary compatibility in the process.
1// Fragile: every member is part of the public contract2class Session {3public:4 void send(const char* msg);5private:6 int fd;7 int retries; // adding one more field here breaks every caller8};9 10// Stable: the public contract is one pointer11class Session {12public:13 Session();14 ~Session();15 void send(const char* msg);16private:17 struct Impl; // declared, never defined in this header18 Impl* p; // sizeof(Session) is one pointer, forever19};The second form also forces the destructor out of line — it must be defined where Impl is complete — which is why pimpl classes conventionally declare a destructor even when they would not otherwise need one. That declaration is not boilerplate; it is what keeps the deletion of an incomplete type out of the caller.
Symbol versioning: make an incompatible change into a name change
The other production technique attacks the problem from [[name-mangling]]'s side. If an incompatible change must happen, give the new behavior a new symbol and keep the old symbol working. Then existing binaries continue to resolve to the old implementation and newly compiled ones bind to the new one, and the linker — which can only compare names — becomes able to see the difference after all.
On ELF this is formalised as symbol versioning: a shared library carries a version script assigning each exported symbol a version tag, an object records which version it referenced, and the dynamic loader binds accordingly. glibc uses this heavily; it is why one libc.so.6 can serve binaries compiled over two decades, with several implementations of memcpy and realpath living side by side under different version tags.
This is also the mechanism behind an error every Linux engineer eventually meets: version GLIBC_2.34 not found. The binary was compiled against a newer glibc, so it references a symbol at a version tag the older runtime does not define. That is not a missing library — the library is right there — it is a version tag that does not exist, and it is the system correctly refusing to bind a reference whose contract it cannot honour. [[dynamic-linking]] covers the runtime half.
The cruder relative is reserved padding: publish a struct with a block of unused bytes so that future fields can be added inside the existing size. It works, it is what many C libraries do, and it is a bet on how much you will need. Guess low and you are back where you started; guess high and every object carries dead weight.
- Opaque handle types:
typedef struct foo* foo_t;withstruct foodefined only in the implementation. The C equivalent of pimpl and the reason most stable C libraries look the way they do. - A size or version field as the first member, filled in by the caller, so the library can detect which vintage of the struct it was handed.
- Symbol versioning on ELF, so an incompatible change becomes a new versioned symbol and old binaries keep resolving to the old one.
- Reserved padding, betting on future growth within a frozen size.
- Bumping the soname —
libfoo.so.2— which does not preserve compatibility but does make the break explicit and lets both versions be installed at once. See[[shared-libraries]].
Rust has no stable ABI, on purpose
#[repr(C)] is the opt-in that freezes layout to the platform C rules. Swift's resilience is likewise a mode: a Swift framework built without library evolution has the same non-stable behavior Rust does.Rust's default representation for a struct is deliberately unspecified. The compiler may reorder fields to minimise padding, and it makes no promise that two compiler versions lay out the same type identically. #[repr(Rust)] is explicitly not a contract. There is no stable Rust ABI and none is planned.
This looks like an omission and is a position. Everything in this lesson is a cost paid for the ability to distribute a binary that other, separately built binaries link against. Rust's ecosystem builds from source, with the whole dependency graph compiled by one compiler in one invocation, so it pays none of that cost and collects the benefits: automatic field reordering to eliminate padding, niche optimizations that make Option<&T> the same size as &T, and freedom to change representations between releases.
When a Rust library genuinely must present a stable boundary it opts in explicitly, with #[repr(C)] on the types and extern "C" on the functions — which is to say, it stops using the Rust ABI and uses the C one, exactly as every other language does. The design is that stability is a per-item declaration rather than a language-wide promise, and the surface that is frozen is the surface you marked.
Swift went the other way and is the instructive contrast: it stabilised its ABI in Swift 5 precisely so that the standard library could ship inside the operating system rather than inside every application. It achieved that with library evolution mode, where types can be resilient — accessed through accessors and computed offsets rather than baked-in ones — which is pimpl generalised, applied automatically, and paid for in the same indirection.
How it works
The steps, in the order the compiler takes them.
- The library author publishes a header; the compiler computes the layout from it and every caller bakes the resulting constants into its instruction stream.
- The library changes, and the layout it computes from its own header no longer matches the constants in the shipped callers.
- Nothing compares the two: the object file records names and relocations, not layouts, so the link succeeds.
- At runtime the loader binds the symbols and the mismatched accesses execute, reading and writing at the wrong offsets.
- Stability techniques all break this chain at step one, by ensuring the caller never obtains a constant to bake in — an opaque pointer, an accessor call, or a size supplied at runtime.
- Symbol versioning instead breaks it at step three, by making the incompatible entity a different name so the mismatch becomes visible to the linker and the loader.
How it breaks
What the engineer observes when it goes wrong — not what goes wrong internally.
- A distribution upgrades a shared library and unrelated applications begin crashing or producing wrong results, with stack traces pointing inside the library at code that did not change.
- A plugin built against a newer SDK is loaded into an older host, a vtable slot has shifted, and a virtual call reaches the wrong method — occasionally running successfully with the wrong arguments, which delays discovery for months.
- A program built on a newer distribution fails to start on an older one with
version GLIBC_2.34 not found, even though the library is installed and present. - A caller stack-allocates an object whose class gained a member, and the library's constructor writes past it, corrupting an adjacent local. The crash is in an unrelated function later.
- A header-only library changes an inline function's behavior and half the program keeps the old behavior, because those translation units were not rebuilt — the classic stale-object-file bug that a clean build hides.
- Two versions of the same library end up in one process through different dependency paths, and objects created by one are destroyed by the other.
When it helps
- Publishing a library that others will link against as a binary — a system library, an OS framework, a plugin SDK, anything distributed by a package manager independently of its consumers.
- Shipping a security fix to a shared library that the entire system picks up without recompiling anything, which is the single largest practical benefit of
[[dynamic-linking]]. - Any plugin architecture, where the host and the plugin are built by different people at different times, and the boundary is the only thing standing between them.
When it hurts
- A statically linked application or a monorepo built hermetically. Everything is compiled together, so no assumption ever outlives its source, and designing for stability costs indirection for nothing.
- Performance-critical boundaries. The techniques that provide stability — indirection, accessors, no inlining — are exactly the ones that remove the optimizer's ability to see across the call.
What it costs
Every one of these is paid by something.
- A stable ABI buys callers that survive upgrades, security patches applied without recompiling the world, and long-lived plugin ecosystems; it costs the permanent freezing of every published type and the design debt that accumulates from decisions made before the requirements were understood.
- Pimpl buys freedom to change internals and costs a heap allocation per object, an indirection per member access, and all cross-boundary inlining.
- Symbol versioning buys incompatible changes without breaking old binaries, and costs a growing symbol table containing every historical implementation, plus real complexity in the build and the loader.
- Reserved padding buys some future fields inside a frozen size and costs memory in every instance for as long as it goes unused — and provides nothing at all once the reserve is exhausted.
- Refusing to have a stable ABI, as Rust does, buys layout optimizations and representational freedom, and costs the ability to distribute compiled libraries at all: everything must be rebuilt with the same compiler.
What else you could do
What a different compiler or language does instead, and when that is better.
- Static linking, which sidesteps the entire problem by making every dependency part of the artifact — and reintroduces it as a need to rebuild and redeploy for any library fix, including security ones. See
[[static-linking]]. - Process boundaries: put the unstable component behind IPC or a socket and exchange serialised messages. The layout question disappears, replaced by a schema-evolution question with far better tooling and a much higher per-call cost.
- A C interface with opaque handles for everything, which is what every long-lived native SDK converges on because it is the only boundary all toolchains agree about.
- Compile-everything-from-source distribution, as Rust's crates.io, Gentoo and most language package managers do. Stability stops mattering because nothing is distributed in compiled form.
- Runtime feature and version negotiation: expose a version query and let callers adapt. Explicit, testable, and it only works for the changes you anticipated well enough to query for.
See it for yourself
The flag, dump or tool that shows you this directly.
- Diff two builds mechanically:
abidiff old.so new.sofrom libabigail reports layout, symbol and type changes that affect binary compatibility. - See what is exported and at which version:
readelf --dyn-syms lib.solists dynamic symbols with their version tags, andreadelf -V lib.soprints the version definition table. - See the layout the compiler chose:
clang -Xclang -fdump-record-layouts -c file.cpp, which prints offsets, sizes and vtable slot assignments. - Reproduce the glibc error deliberately: build on a newer distribution and run in an older container image. The message names the version tag, which is the whole diagnosis.
- Check a Rust type's layout instability for yourself: print
std::mem::size_ofandoffset_offor a struct with mixed field sizes, with and without#[repr(C)], and observe the reordering.
Plausible wrong readings
Stated the way a confident engineer states them.
- "Adding a private field is an implementation detail." It is a size change, and size is the most public thing about a type. Private controls access, not layout.
- "Semantic versioning covers this." Semver describes source compatibility unless a project says otherwise. Binary compatibility is a separate axis and needs a separate promise, which is what sonames and version tags exist to express.
- "Rust's lack of a stable ABI is a missing feature." It is a deliberate trade that buys field reordering and niche optimizations, with
#[repr(C)]as the explicit opt-out when a frozen boundary is needed. - "If it starts up, the versions are compatible." Only symbol resolution is checked at load. Layout mismatches produce no loader error and surface as corruption later.
Misconceptions
The claim, and what is actually true.
Go deeper
The same idea at increasing depth. Stop wherever it stops being useful.
overview
When a library ships, every program already compiled against it contains that library's type sizes and field offsets as numbers inside its own instructions. Change the type and those numbers become wrong, with nothing to warn anyone. The only reliable defence is to make sure callers never learn the numbers — hide the state behind a pointer they cannot see through.
practical
If your library is distributed as a binary: publish opaque handles, keep constructors and destructors out of line, avoid inline functions and templates on the boundary, use fixed-width integer types, and add new functions rather than changing existing signatures. If it must break, bump the soname so both versions can coexist. If your code is always built from source with its consumers, ignore all of this and take the inlining.
advanced
The structural insight is that ABI stability and optimization are competing for the same resource: knowledge of a representation. An optimizer is fast in proportion to how much it knows about layout — field offsets, sizes, whether a call can be inlined, whether a value fits in a register. A stable ABI is a promise not to use that knowledge, or more precisely a promise that whatever knowledge callers have will remain true. Swift's resilience makes the trade explicit and per-declaration: a type marked @frozen gives up future flexibility to get direct field access, and everything else pays an accessor call. That is the honest general form of the choice, and it explains why the two large ecosystems chose oppositely — Apple needed the standard library inside the OS and accepted the indirection, Rust needed the optimizations and accepted rebuilding the world.
How much this depends on
Nothing in this domain is true of every compiler. These say how much.
If you were asked this in an interview
- A shared library adds a private
intto a published class. What happens to a program compiled against the old version, and at which point could anything detect it? - What does pimpl buy and what does it cost?
- Why does Rust not have a stable ABI, and what do you use when you need one?
Connections
- DevOps / Production Engineering — Rolling out a shared dependency across independently released artifactsEverything here exists because the library and its callers are built and shipped at different times. Whether that is even true of a given system — and how upgrades are staged when it is — is a release-engineering question that decides how much of this lesson applies.
- Programming Languages & Runtime Internals — Resilient object access: computed field offsets and accessor-based layout at runtimeSwift's library evolution makes field access an indirect operation resolved at run time rather than a compiled constant. The compiler emits that indirection; what the runtime does to make it cheap — offset caching, layout metadata — belongs there.