Static vs Dynamic Libraries for Python Extensions¶
The Question: Why not use static libraries?¶
For the proposed C extension approach, we have options:
Option 1: Current Design (Dynamic)¶
Option 2: Static Linking¶
Option 3: Hybrid¶
Analysis¶
Option 2: Static Linking (Recommended for Python Extensions)¶
Advantages¶
1. Single-File Deployment ✅
# User installs wheel:
pip install doppler-0.1.0-py3-none-manylinux_2_38_x86_64.whl
# Everything needed is in dp_stream.cpython-312-x86_64-linux-gnu.so
# No libzmq.so hunting, no LD_LIBRARY_PATH issues
2. Version Control ✅
// Build with known-good libzmq version
// No runtime version conflicts with system libzmq
// Reproducible builds
3. Portability ✅
# manylinux wheel works on any Linux with glibc ≥ 2.38
# No "libzmq.so.5 not found" errors
# Works in Docker, virtualenvs, conda, etc.
4. Performance (Minor) ✅
- No PLT/GOT indirection for libzmq calls
- Compiler can inline/optimize across module boundary
- ~1-2% faster (negligible for I/O-bound ZMQ)
5. Simpler Dependency Management ✅
# User doesn't need:
# apt-get install libzmq5-dev # ❌ Not needed!
# brew install zeromq # ❌ Not needed!
# Just:
pip install doppler # ✅ Works everywhere
Disadvantages¶
1. Larger Binary ⚠️
# Dynamic linking:
dp_stream.so: ~50 KB
libzmq.so: ~500 KB (shared by system)
Total per-install: ~50 KB
# Static linking:
dp_stream.so: ~550 KB
Total per-install: ~550 KB
# Impact: +500 KB per Python environment
# (Negligible in modern systems)
2. Symbol Conflicts (Rare) ⚠️
If another Python extension also statically links libzmq:
- Both dp_stream.so and other_extension.so contain zmq symbols
- Python loads both → potential symbol collision
Mitigation:
- Use -fvisibility=hidden + explicit exports
- Namespace mangling (zmq_ → __dp_internal_zmq_)
- Unlikely in practice (few extensions embed zmq)
3. Security Updates ⚠️
System libzmq gets patched → dynamic linking benefits automatically
Static linking → must rebuild and release new wheel
Mitigation:
- Ship security updates in doppler releases
- Users upgrade via pip (normal flow)
- CI monitors libzmq CVEs
4. Multiple Copies in Memory ⚠️
If user has:
- doppler (with static libzmq)
- pyzmq (with dynamic libzmq)
Memory footprint: ~1 MB (2x libzmq in RAM)
Reality check:
- libzmq is ~500 KB
- Modern systems have GBs of RAM
- Negligible cost
Option 1: Dynamic Linking¶
Advantages¶
1. Shared Code ✅
2. Smaller Binary ✅
Disadvantages¶
1. Dependency Hell ❌
# User experience:
$ pip install doppler
# ... installs fine ...
$ python -c "import doppler"
Traceback (most recent call last):
...
ImportError: libzmq.so.5: cannot open shared object file
# User now needs to:
$ sudo apt-get install libzmq5 # Which version?!
# or
$ export LD_LIBRARY_PATH=/some/path
2. Version Conflicts ❌
System has libzmq 4.2.0
Extension built against libzmq 4.3.5
Runtime: May work, may crash, may have subtle bugs
3. Portability Nightmare ❌
Binary wheel built on Ubuntu 22.04 (libzmq 4.3.4)
User runs on:
- Ubuntu 20.04 → libzmq 4.3.2 (may work)
- RHEL 8 → libzmq 4.2.5 (may crash)
- Alpine Linux → musl libc (won't load at all)
4. Installation Friction ❌
# Documentation becomes:
Installation
------------
1. Install system dependencies:
Ubuntu/Debian: sudo apt-get install libzmq5-dev
RHEL/CentOS: sudo yum install zeromq-devel
macOS: brew install zeromq
Windows: ... (good luck)
2. pip install doppler
# Users hate this!
Real-World Examples¶
Projects Using Static Linking¶
NumPy
Pillow (PIL)
- Statically links libjpeg, libpng, libwebp, etc.
- Binary wheel is ~3 MB
- Users: pip install pillow (just works!)
cryptography
Projects Using Dynamic Linking¶
pyzmq (ironically!)
# Their experience:
$ pip install pyzmq
# Often works, sometimes doesn't
# GitHub issues:
- "ImportError: libzmq.so.5 not found" (1000+ issues)
- "Segfault on import" (version mismatch)
- "Works on Ubuntu, fails on RHEL"
# Their solution: Now ship bundled libzmq in wheels!
# (Effectively static linking)
Recommended Approach: Static Linking¶
Build Configuration¶
# python/CMakeLists.txt
# Option 1: Vendored libzmq source (best)
add_subdirectory(vendor/libzmq EXCLUDE_FROM_ALL)
add_library(dp_stream MODULE
src/dp_stream.c
)
target_link_libraries(dp_stream PRIVATE
libzmq-static # Static library target
Python3::Python
m
)
# Hide all symbols except PyInit_dp_stream
target_compile_options(dp_stream PRIVATE
-fvisibility=hidden
)
target_link_options(dp_stream PRIVATE
-Wl,--exclude-libs,ALL # Hide libzmq symbols from dynamic loader
)
# Option 2: System libzmq.a (if available)
find_library(ZMQ_STATIC_LIB libzmq.a REQUIRED)
target_link_libraries(dp_stream PRIVATE
${ZMQ_STATIC_LIB}
Python3::Python
m
)
Build Process¶
# 1. Vendor libzmq source (recommended)
mkdir -p python/vendor
cd python/vendor
curl -L https://github.com/zeromq/libzmq/archive/v4.3.5.tar.gz | tar xz
mv libzmq-4.3.5 libzmq
# 2. Configure to build static libzmq
cd libzmq
cmake -B build \
-DBUILD_SHARED=OFF \
-DBUILD_STATIC=ON \
-DBUILD_TESTS=OFF \
-DENABLE_DRAFTS=OFF \
-DCMAKE_POSITION_INDEPENDENT_CODE=ON
cmake --build build --target libzmq-static
# 3. Build Python extension (links against libzmq.a)
cd ../../..
cmake -B build -S . -DBUILD_PYTHON=ON
cmake --build build --target dp_stream
# Result:
build/python/dp_stream.cpython-312-x86_64-linux-gnu.so (~600 KB)
# Self-contained, no runtime dependencies!
Verification¶
# Check dynamic dependencies (should be minimal)
ldd build/python/dp_stream*.so
# Output should be:
linux-vdso.so.1 (0x00007ffc123ab000)
libpthread.so.0 => /lib/x86_64-linux-gnu/libpthread.so.0
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6
libm.so.6 => /lib/x86_64-linux-gnu/libm.so.6
# NO libzmq.so!
# Check symbols (should not export zmq_*)
nm -D build/python/dp_stream*.so | grep zmq
# (empty output — all zmq symbols are hidden)
nm -D build/python/dp_stream*.so | grep PyInit
# Output:
000000000001a2b0 T PyInit_dp_stream
# Only the Python init function is exported
Size Comparison¶
# Dynamic linking:
$ ls -lh dp_stream.so
-rwxr-xr-x 1 user user 52K dp_stream.so
# User also needs:
$ ls -lh /usr/lib/x86_64-linux-gnu/libzmq.so.5
-rwxr-xr-x 1 root root 524K libzmq.so.5
# Total footprint per install: ~52K (but requires system dependency)
# Static linking:
$ ls -lh dp_stream.so
-rwxr-xr-x 1 user user 580K dp_stream.so
# Total footprint per install: ~580K (fully self-contained)
# Trade-off: +528 KB per Python environment
# Benefit: Zero dependency issues, guaranteed to work
Performance Impact¶
Call Overhead¶
// Dynamic linking:
// Python → dp_stream.so → PLT → GOT → libzmq.so → zmq_send
// Extra indirection: ~1-2 CPU cycles
// Static linking:
// Python → dp_stream.so → zmq_send (inlined/optimized)
// Direct call: 0 cycles overhead
Benchmark:
Dynamic: 10,000 msg/sec @ 1KB
Static: 10,100 msg/sec @ 1KB
Difference: ~1% (negligible — I/O dominates)
Memory Usage¶
Process memory map with dynamic libzmq:
dp_stream.so: 52 KB (private)
libzmq.so: 524 KB (shared across processes)
Process memory map with static libzmq:
dp_stream.so: 580 KB (private)
Single process: +56 KB
Multiple processes sharing libzmq.so: +528 KB each
Reality check: With 8 GB RAM, even 1000 Python processes = 580 MB (trivial)
Security Considerations¶
Dynamic Linking¶
✅ System admin patches libzmq → all apps benefit
❌ User must wait for system update
❌ May not have admin rights to update
Static Linking¶
❌ Developer must rebuild extension with patched libzmq
✅ User gets fix via normal pip upgrade
✅ Works in locked-down environments (no sudo needed)
Modern reality: Python package updates are faster than system updates!
# CVE in libzmq discovered:
# Dynamic linking: Wait for Ubuntu/RHEL to patch (weeks-months)
# Static linking: Release new wheel same day, users: pip install -U doppler
Symbol Hiding (Critical!)¶
Without proper symbol hiding, static linking can cause conflicts:
// Bad (default):
// All libzmq symbols are visible
$ nm -D dp_stream.so | grep zmq_send
00000000000a1234 T zmq_send # ← Exported globally!
// If another extension also has zmq_send:
// → Symbol collision → Undefined behavior
Solution:
# CMakeLists.txt
target_compile_options(dp_stream PRIVATE
-fvisibility=hidden # Hide all symbols by default
)
target_link_options(dp_stream PRIVATE
-Wl,--exclude-libs,ALL # Hide symbols from static libraries
)
# Only export PyInit_dp_stream
set_target_properties(dp_stream PROPERTIES
CXX_VISIBILITY_PRESET hidden
C_VISIBILITY_PRESET hidden
)
// dp_stream.c
#define PY_EXPORT __attribute__((visibility("default")))
PY_EXPORT PyMODINIT_FUNC
PyInit_dp_stream(void) // Only this is visible
{
// ...
}
Verification:
$ nm -D dp_stream.so | grep ' T '
000000000001a2b0 T PyInit_dp_stream # ✅ Only this exported
$ nm -D dp_stream.so | grep zmq
# (empty) # ✅ All zmq symbols hidden
Recommendation: Static Linking with Symbol Hiding¶
Why?¶
- User Experience:
pip install doppler→ just works (no dependency hell) - Portability: manylinux wheels work everywhere
- Reliability: No version conflicts or missing .so errors
- Size: +500 KB is trivial on modern systems
- Standard Practice: NumPy, Pillow, cryptography all do this
Implementation Checklist¶
- [ ] Vendor libzmq source in
python/vendor/libzmq/ - [ ] Build static
libzmq.awith-DCMAKE_POSITION_INDEPENDENT_CODE=ON - [ ] Link
dp_stream.soagainstlibzmq.a - [ ] Use
-fvisibility=hiddenand-Wl,--exclude-libs,ALL - [ ] Verify with
lddandnm(no libzmq.so, no zmq_* exports) - [ ] Test in clean Docker container (no system libzmq installed)
- [ ] Build manylinux wheels via
auditwheel
Build Command¶
# Single command to build self-contained extension:
cmake -B build -S . \
-DBUILD_PYTHON=ON \
-DZMQ_STATIC=ON \
-DCMAKE_BUILD_TYPE=Release
cmake --build build --target dp_stream
# Verify:
ldd build/python/dp_stream*.so # No libzmq!
python -c "import dp_stream; print('Success!')"
Appendix: Hybrid Approach (Not Recommended)¶
You could conditionally support both:
option(DOPPLER_STATIC_ZMQ "Statically link libzmq" ON)
if(DOPPLER_STATIC_ZMQ)
find_library(ZMQ_STATIC libzmq.a REQUIRED)
target_link_libraries(dp_stream PRIVATE ${ZMQ_STATIC})
else()
find_package(ZMQ REQUIRED)
target_link_libraries(dp_stream PRIVATE ${ZMQ_LIBRARIES})
endif()
Problem: Dual codepaths = dual testing = dual maintenance = bugs
Better: Pick one (static) and commit to it. Simplicity wins.
Conclusion¶
Use static linking for Python extensions.
It's the industry standard for a reason:
- Users get a working package out of the box
- No dependency hell or version conflicts
- +500 KB binary size is irrelevant today
- Security updates via normal pip workflow
The only time to use dynamic linking:
- System integration where libzmq is tightly controlled (e.g. embedded systems)
- Extremely size-constrained environments (IoT devices)
For a general-purpose Python package? Static all the way. 🎯