Why Library Versioning Rules Matter
Shared libraries are used by many programs simultaneously. When you update a library, you must decide: does this change break existing programs or not? Getting this wrong leads to DLL Hell (on Windows) or broken systems on Linux.
Linux solves this with a strict versioning convention backed by the soname mechanism. The key question every library maintainer must answer: is my change ABI-compatible or ABI-incompatible?
ABI (Application Binary Interface) is the low-level contract between a shared library and the programs that use it — function signatures, data structures, calling conventions, symbol names.
1. Understanding Version Numbers
A shared library real name follows this format:
| lib | demo | .so. | 1 | . | 0 | . | 1 |
| Library name | Major version |
Minor version |
Patch level |
||||
Changes when the library has ABI-breaking changes. Old programs compiled against major version 1 will NOT work with major version 2. They must be recompiled.
Changes for backward-compatible updates — bug fixes, performance improvements, new functions. Old programs still work without recompiling.
Minor bug fixes within the same minor version. Not all libraries use this third component.
2. The Three Conditions for Compatibility
A library change is compatible (minor version bump only) if and only if all three of these conditions are met:
Every public function must keep the same argument types, same return type, and same behavior. Performance improvements and bug fixes that don’t change the observable contract are fine. The function must still do what its documentation says.
You must not remove any function or variable from the public API. It is, however, compatible to add new public functions and variables. Adding does not break existing programs since they don’t know about (and don’t call) the new additions.
Structures allocated by or returned from library functions must not change layout. If a struct changes size or field offsets, programs compiled against the old struct will read/write wrong memory. Exception: adding fields at the end of a struct can sometimes be safe, but is risky if callers allocate arrays of the struct.
libdemo.so.2) so that old programs continue to find and use the old version, while new programs can be written to use the new version.3. Compatible vs Incompatible — Quick Reference
- Fix a bug (same contract, better behavior)
- Improve performance of an existing function
- Add a new function to the public API
- Add a new global variable
- Add padding fields at end of a struct (with care)
- Change internal (private) implementation
- Add internal helper functions not in header
- Change function argument types or count
- Change function return type
- Remove a public function or variable
- Change the size of a public struct
- Change the order of fields in a struct
- Change semantics of an existing function
- Change calling convention
Code Example 1 — A Compatible Change (Minor Version Bump)
In version 1.0.1 of libdemo, add_numbers takes two ints. In 1.0.2 we fix a bug and add a new function multiply_numbers. This is compatible — no existing caller breaks.
/* ===== libdemo version 1.0.1 ===== */
/* mod1.c */
int add_numbers(int a, int b) {
return a + b; /* original */
}
void hello_from_mod1(void) {
printf("libdemo v1.0.1\n");
}
/* ===== libdemo version 1.0.2 — COMPATIBLE CHANGE ===== */
/* mod1.c */
#include <stdio.h>
/* Same function, same signature — bug fix inside */
int add_numbers(int a, int b) {
/* Fixed: previously had off-by-one in some edge case */
return a + b;
}
/* NEW function added — does NOT break existing callers */
int multiply_numbers(int a, int b) {
return a * b;
}
void hello_from_mod1(void) {
printf("libdemo v1.0.2 (bug fixed)\n");
}
/* demo.h — updated public header for v1.0.2 */
#ifndef DEMO_H
#define DEMO_H
/* Existing functions — unchanged */
int add_numbers(int a, int b);
void hello_from_mod1(void);
void hello_from_mod2(void);
/* New function — safely added */
int multiply_numbers(int a, int b);
#endif
# Build version 1.0.2 — SAME soname (libdemo.so.1) because compatible
gcc -g -c -fPIC -Wall mod1.c mod2.c
gcc -g -shared -Wl,-soname,libdemo.so.1 \
-o libdemo.so.1.0.2 \
mod1.o mod2.o
# Install and run ldconfig
sudo mv libdemo.so.1.0.2 /usr/local/lib/
sudo ldconfig -v | grep libdemo
# libdemo.so.1 -> libdemo.so.1.0.2 (changed — soname now points to 1.0.2)
# Old programs still work — they load libdemo.so.1 -> libdemo.so.1.0.2
# They call add_numbers(), which still exists with same signature → OK
# They never call multiply_numbers() — they don't know it exists → no problem
Code Example 2 — An Incompatible Change (New Major Version)
In version 2.0.0, we change add_numbers to take a third parameter and remove hello_from_mod2. Old programs that call these functions will break — so we need a new major version.
/* ===== libdemo version 2.0.0 — INCOMPATIBLE CHANGE ===== */
/* mod1.c */
#include <stdio.h>
/* CHANGED: third parameter added — breaks all old callers! */
int add_numbers(int a, int b, int c) {
return a + b + c;
}
/* CHANGED: return type changed from void to int — breaks old callers! */
int hello_from_mod1(void) {
printf("libdemo v2.0.0\n");
return 0;
}
/* hello_from_mod2 REMOVED — old programs calling it will crash at link time */
/* demo.h — v2 header: INCOMPATIBLE with v1 */
#ifndef DEMO_H
#define DEMO_H
/* Changed signature */
int add_numbers(int a, int b, int c);
/* Changed return type */
int hello_from_mod1(void);
/* hello_from_mod2 removed */
#endif
# Build version 2.0.0 — NEW soname (libdemo.so.2) — DIFFERENT from v1!
gcc -g -c -fPIC -Wall mod1.c
# NOTE: soname is now libdemo.so.2
gcc -g -shared -Wl,-soname,libdemo.so.2 \
-o libdemo.so.2.0.0 \
mod1.o
# Install
sudo mv libdemo.so.2.0.0 /usr/local/lib/
sudo ldconfig -v | grep libdemo
# libdemo.so.1 -> libdemo.so.1.0.2 (v1 still present, untouched)
# libdemo.so.2 -> libdemo.so.2.0.0 (new v2 soname created)
# Create linker name pointing to v2
sudo ln -sf libdemo.so.2 /usr/local/lib/libdemo.so
# -------------------------------------------------------
# Old program compiled against v1:
ldd old_program
# libdemo.so.1 => /usr/local/lib/libdemo.so.1.0.2 ← still gets v1! WORKS.
# New program compiled against v2:
gcc -o new_program new_main.c -ldemo -L/usr/local/lib
ldd new_program
# libdemo.so.2 => /usr/local/lib/libdemo.so.2.0.0 ← gets v2. WORKS.
Code Example 3 — The Struct Padding Technique
Library designers sometimes reserve space in exported structs for future use, allowing fields to be added without breaking the ABI. This is a common technique for stable library ABIs.
/* ===== demo.h — v1.0.x with reserved padding ===== */
#ifndef DEMO_H
#define DEMO_H
/*
* The struct has 'reserved' fields to allow future additions
* WITHOUT changing the struct's total size.
* This is the "padding for future use" technique.
*/
typedef struct {
int id; /* offset 0 */
int value; /* offset 4 */
char name[64]; /* offset 8 */
int flags; /* offset 72 */
/* Reserved fields — currently unused, set to 0 */
int _reserved1; /* offset 76 — for future use */
int _reserved2; /* offset 80 — for future use */
void *_reserved3; /* offset 84 — for future use */
} DemoRecord;
DemoRecord *demo_create(int id, int value, const char *name);
void demo_destroy(DemoRecord *rec);
void demo_print(const DemoRecord *rec);
#endif
/* mod_record.c — implementation */
#include <stdlib.h>
#include <string.h>
#include <stdio.h>
#include "demo.h"
DemoRecord *demo_create(int id, int value, const char *name) {
DemoRecord *rec = calloc(1, sizeof(DemoRecord)); /* calloc zeros all reserved fields */
if (!rec) return NULL;
rec->id = id;
rec->value = value;
strncpy(rec->name, name, sizeof(rec->name) - 1);
return rec;
}
void demo_destroy(DemoRecord *rec) {
free(rec);
}
void demo_print(const DemoRecord *rec) {
printf("id=%d value=%d name=%s flags=%d\n",
rec->id, rec->value, rec->name, rec->flags);
}
/* ===== Later: v1.1.0 — USE one reserved field, NO ABI break ===== */
/* New demo.h: _reserved1 is now 'priority', struct SIZE is same */
typedef struct {
int id;
int value;
char name[64];
int flags;
/* _reserved1 is now officially 'priority' */
int priority; /* offset 76 — newly defined field */
int _reserved2;
void *_reserved3;
} DemoRecord;
/* Add new function that uses priority */
void demo_set_priority(DemoRecord *rec, int pri);
/*
* Why is this still compatible?
*
* Old programs allocate DemoRecord — size is still the same (reserved space holds it).
* Old programs never set 'priority' — it stays 0 (calloc initializes to 0).
* Old programs call demo_create/demo_destroy/demo_print — all still exist, same signature.
* Only NEW programs can use demo_set_priority — old programs simply don't call it.
*
* Result: minor version bump to 1.1.0, same soname libdemo.so.1 — COMPATIBLE.
*/
# Build v1.1.0 — still soname libdemo.so.1
gcc -g -shared -Wl,-soname,libdemo.so.1 \
-o libdemo.so.1.1.0 \
mod_record.o
sudo mv libdemo.so.1.1.0 /usr/local/lib/
sudo ldconfig -v | grep libdemo
# libdemo.so.1 -> libdemo.so.1.1.0 (changed, still v1 soname)
stat struct has historically used padding fields. glibc’s struct timespec reserved space for nanosecond timestamps long before they were officially supported, allowing a smooth transition without breaking ABI.4. Version Decision Guide
Use this decision table when making a library change:
| Change You Are Making | Compatible? | Version Action |
|---|---|---|
| Fix a bug without changing function signature | ✔ Yes | Bump minor (1.0.1 → 1.0.2) |
| Add a new exported function | ✔ Yes | Bump minor (1.0.1 → 1.1.0) |
| Change a function’s parameter types | ✘ No | New major (libdemo.so.2) |
| Remove a public function | ✘ No | New major (libdemo.so.2) |
| Add fields to middle of a public struct | ✘ No | New major (libdemo.so.2) |
| Add padding fields at end of struct (carefully) | ⚠ Maybe | Minor bump if callers don’t allocate arrays of struct |
| Improve performance, no behavior change | ✔ Yes | Bump minor |
| Change function return type | ✘ No | New major (libdemo.so.2) |
Interview Questions & Answers
libXYZ.so.1), so programs compiled against any 1.x.y version find and use the latest 1.x.y without recompiling.rec->value becomes “read 4 bytes from address (rec + 4)”. If the library changes the struct layout in a new version (shifting fields), the offsets change. Old programs still use the old offsets and will read from the wrong memory location. This causes silent data corruption or crashes — without any compile-time error. This is why struct layout changes always require a new major version._reserved1, _reserved2, etc.) to exported structs beyond what is immediately needed. These fields are always initialized to 0. In a future version, the library can “use” a reserved field by officially naming and documenting it, without changing the struct’s total size or field offsets. This allows adding new struct fields without bumping the major version. However, this only works if callers do not allocate arrays of the struct — if they do, and the struct size ever changes despite the reservation, it would still break.error while loading shared libraries: libdemo.so.1: cannot open shared object file: No such file or directory. The dynamic linker looks for the soname libdemo.so.1 in the cache and library paths. Since it doesn’t exist (only libdemo.so.2 is installed), the load fails. The program cannot use a different major version — this is exactly the protection the major version mechanism provides.libdemo.so.1.x.y and libdemo.so.2.x.y can coexist in the same directory. ldconfig creates two separate soname symlinks: libdemo.so.1 and libdemo.so.2. Programs compiled against v1 have libdemo.so.1 recorded in their ELF — they always load from the v1 chain. Programs compiled against v2 have libdemo.so.2 recorded — they load from the v2 chain. The two versions are completely independent at runtime, and both can be active in memory simultaneously (in different processes, or even the same process if it deliberately links both).Continue to Part 3
Learn how to perform live upgrades of shared libraries while programs are running.
