Symbol Visibility and Namespace Isolation for Spatial Extensions
When two Python geospatial packages each vendor their own GEOS or PROJ and both are imported into one interpreter, their identically-named symbols collide in the process’s global symbol table, and the second dlopen can bind the first library’s function pointers against the second library’s data structures — a corruption that surfaces as a mid-run Segmentation fault. This guide sits under the Geospatial C-Extension Fundamentals & ABI Architecture reference and covers the compile-time and link-time controls — -fvisibility=hidden, linker version scripts, and RTLD_LOCAL loading — that keep each extension’s native dependencies in their own namespace. It targets GCC 13 / Clang 16, GEOS 3.11+, GDAL 3.8+, and wheels built to the abi3 contract from C-API vs CPython ABI compatibility.
Prerequisites & Environment
- GCC 13 or Clang 16 with support for
-fvisibility=hiddenand__attribute__((visibility("default"))). - A linker (
ld/lld) that honours version scripts (--version-script) on Linux, or-exported_symbols_liston macOS. - The extension’s vendored GEOS/PROJ built as static archives or private shared objects, not linked against a shared system copy.
nm -Dandreadelf --dyn-symsto audit what the finished extension exports.
# See how many symbols your extension currently exports — should be ~1
nm -D --defined-only build/_geospatial_ext*.so | wc -l
A healthy abi3 geospatial extension exports exactly one symbol: its PyInit_ function. Anything more is a collision risk.
Core Configuration
Two flags do most of the work. -fvisibility=hidden makes every symbol hidden by default, so only symbols you explicitly mark as visible are exported; a version script then narrows the export list to the single module entry point.
# Compile every translation unit with hidden default visibility
CFLAGS += -fvisibility=hidden -fvisibility-inlines-hidden
# Export only the module init symbol via a version script
LDFLAGS += -Wl,--version-script=export.map
# export.map — the ONLY symbol the extension exposes to the process
{
global: PyInit__geospatial_ext;
local: *;
};
The local: * line hides everything else, including the entire vendored GEOS and PROJ symbol set, so a second extension’s copy can never bind against it. This is the compile-time complement to the runtime isolation covered in securely compiling spatial C-extensions.
Step-by-Step Implementation
-
Mark the module init function visible. With hidden default visibility, the init function must be explicitly exported or the interpreter cannot find it:
#define PYEXPORT __attribute__((visibility("default"))) PYEXPORT PyMODINIT_FUNC PyInit__geospatial_ext(void) { /* ... */ } -
Add the version script to the link. Confirmed above; on macOS use
-Wl,-exported_symbols_list,export.symswith a single_PyInit__geospatial_extline. -
Static-link the geospatial dependencies so their symbols are absorbed into the extension and then hidden, rather than exported by a shared object the loader can see.
-
Audit the export list before shipping (see Verification). One symbol out means one symbol that can collide.
Verification
# 1. The extension must export exactly its init symbol
nm -D --defined-only build/_geospatial_ext*.so | grep -v ' PyInit_'
# expected: empty
# 2. GEOS/PROJ symbols must NOT be dynamically exported
readelf --dyn-syms build/_geospatial_ext*.so | grep -iE 'GEOS|proj_' | grep -i default
# expected: empty (no DEFAULT-visibility GEOS/PROJ symbols)
# 3. Two-extension co-import smoke test
python -c "import _geospatial_ext, shapely, pyproj; print('coexist ok')"
# expected: coexist ok (no Segmentation fault)
A clean run shows only PyInit_ exported and both packages importing together. If GEOS symbols still appear with DEFAULT visibility, the version script did not apply — confirm it is on the final link, not an intermediate object.
Optimization & Edge Cases
RTLD_LOCALis Python’s default, but C++ statics fight it. CPython loads extensions withRTLD_LOCAL, yet a shared C++ runtime can still deduplicate globals. Static-linkinglibstdc++(or ensuring a single vendored copy) prevents cross-extension state bleed, as memory management in geospatial extensions discusses for allocator arenas.- Hidden visibility shrinks the binary. Fewer dynamic symbols means a smaller dynamic symbol table and faster load — a free win alongside the isolation.
- Third-party wheels you do not control. If
shapelyorpyprojexport their GEOS globally, your only defence is to keep your copy hidden; you cannot un-export theirs.
Troubleshooting
ImportError: dynamic module does not define module export function (PyInit__geospatial_ext). Hidden visibility hid the init symbol too. Add __attribute__((visibility("default"))) to the init function and confirm the version script lists it under global:.
Segmentation fault only when a specific second package is imported. Classic symbol collision. Audit both extensions’ exports; hide yours with the version script. See diagnosing undefined-symbol errors for the inverse case where hiding too much leaves a symbol undefined.
Version script silently ignored. Passing --version-script to the compiler instead of the linker drops it. Route it through -Wl, and verify with readelf --dyn-syms.
Related
- Hiding GEOS symbols with version scripts — the complete version-script recipe for a GEOS-vendoring extension.
- Memory management in geospatial extensions — why a private allocator arena depends on the same symbol isolation.
- Diagnosing undefined-symbol errors in spatial extensions — the failure mode when isolation is set too aggressively.
Further Reading
- GCC visibility documentation (
gcc.gnu.org/wiki/Visibility).