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.

Global symbol export versus hidden visibility for two vendored GEOS copies On the left, two extensions each export their vendored GEOS symbols globally, so the process symbol table holds two definitions of GEOSGeomCreate and the second load binds against the first, causing a crash. On the right, both extensions compile with hidden visibility and a version script exporting only their module init function, so each keeps a private GEOS and no collision occurs. global export → collision hidden → isolated ext A + GEOS exports GEOS* ext B + GEOS exports GEOS* one global symbol table B binds A's GEOSGeomCreate Segmentation fault ext A + GEOS exports PyInit only ext B + GEOS exports PyInit only private GEOS per module no shared symbols imports ✓

Prerequisites & Environment

  • GCC 13 or Clang 16 with support for -fvisibility=hidden and __attribute__((visibility("default"))).
  • A linker (ld/lld) that honours version scripts (--version-script) on Linux, or -exported_symbols_list on macOS.
  • The extension’s vendored GEOS/PROJ built as static archives or private shared objects, not linked against a shared system copy.
  • nm -D and readelf --dyn-syms to 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

  1. 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) { /* ... */ }
    
  2. Add the version script to the link. Confirmed above; on macOS use -Wl,-exported_symbols_list,export.syms with a single _PyInit__geospatial_ext line.

  3. 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.

  4. 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_LOCAL is Python’s default, but C++ statics fight it. CPython loads extensions with RTLD_LOCAL, yet a shared C++ runtime can still deduplicate globals. Static-linking libstdc++ (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 shapely or pyproj export 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.

Further Reading

  • GCC visibility documentation (gcc.gnu.org/wiki/Visibility).