Avoiding symbol collisions between spatial wheels

This page answers one question: your package and shapely, pyproj or rasterio each bundle their own GEOS or PROJ, and importing both in one interpreter crashes or returns wrong geometry — so how do you make your wheel incapable of participating in that collision, and how do you tell which side is at fault? It sits inside the Symbol Visibility and Namespace Isolation section of the Geospatial C-Extension Fundamentals & ABI Architecture reference, and gives you the diagnosis, the one-sided fix, and the test that proves it.

How two wheels with their own GEOS end up sharing one Package A is imported first and its bundled GEOS 3.11 symbols enter the process global scope. Package B is imported second, built against GEOS 3.12. The loader satisfies B's GEOS references from the definitions already present, so B's code calls A's library. Because the two versions differ in struct layout, the call succeeds and returns a value computed from misread memory. A side note records that the crash usually appears later, in unrelated code. import package A bundles GEOS 3.11 import package B built against GEOS 3.12 process global scope: GEOSGeom_createPoint_r → A's 3.11 definition B's references resolve here because A got there first B passes a 3.12 struct into 3.11 code fields read at the wrong offsets — no error, a plausible answer the crash arrives later, in code that did nothing wrong — which is why the stack trace names the innocent caller

Context & Root Cause

CPython loads extension modules into one process, and on Linux the dynamic loader resolves a symbol to the first definition that entered the global scope. Two packages that each vendor GEOS therefore contribute the same several hundred symbol names, and whichever package was imported first supplies all of them — to itself and to everyone else.

The failure is not a name clash a compiler could catch. Both packages are internally consistent; the loader’s rule is reasonable in isolation; and the mismatch only manifests when one version’s struct layout differs from the other’s. Because both libraries are called through the same C API, the wrong call succeeds and returns a value, so the crash appears later in a function that did nothing wrong. This is the mechanism the parent symbol visibility and namespace isolation guide describes; the practical question here is what to do about it when you control only one of the two packages.

Solution / Fix

This targets GCC 13 / Clang 16 on Linux, with the macOS equivalent noted, and GEOS 3.11+ or PROJ 9.2+ vendored into your wheel.

1. Confirm the diagnosis with an order-dependent test

python -c "import mypkg, shapely.geometry; print('A first: ok')"
python -c "import shapely.geometry, mypkg; print('B first: ok')"
# a failure in exactly one order is a collision, not a bug in either package

2. Hide every symbol except the module entry point

CFLAGS="$CFLAGS -fvisibility=hidden -fvisibility-inlines-hidden"
LDFLAGS="$LDFLAGS -Wl,--version-script=export.map"
# export.map
{ global: PyInit__geospatial_ext; local: *; };

3. Absorb the vendored library rather than shipping it as a shared object

# Static GEOS, hidden by the version script, exports nothing
find_package(GEOS 3.12 REQUIRED)
target_link_libraries(_geospatial_ext PRIVATE GEOS::geos_c_static)
set_target_properties(_geospatial_ext PROPERTIES
  C_VISIBILITY_PRESET hidden
  CXX_VISIBILITY_PRESET hidden)

4. Assert the outcome in the build

nm -D --defined-only build/_geospatial_ext*.so | grep -v ' PyInit_' && exit 1
echo "exports clean"

Verification

# 1. Your extension exports exactly one symbol
nm -D --defined-only /tmp/w/**/_geospatial_ext*.so | wc -l
# expected: 1
# 2. Both import orders work, repeatedly
for i in $(seq 20); do
  python -c "import mypkg, shapely.geometry, pyproj" || exit 1
  python -c "import pyproj, shapely.geometry, mypkg"  || exit 1
done && echo "coexists in both orders"
# 3. Under LD_DEBUG, your GEOS calls resolve inside your own module
LD_DEBUG=bindings python -c "import shapely.geometry, mypkg; mypkg.buffer_test()" 2>&1 \
  | grep -m3 GEOSBuffer
# expected: bindings to your own object, never to shapely's

The second check is worth running in a loop rather than once. Collisions are deterministic given an import order, so twenty iterations of two orders is not about flakiness — it is about covering the orders your users will produce, including the ones a test suite’s import graph does not.

When the Other Package Is the One Leaking

Hiding your symbols protects you completely, and it does not protect the other package from you being imported first. If your wheel exports nothing, your library cannot serve anyone else’s references — but if their wheel exports GEOS globally and yours does not, then whichever of you loads first is still theirs, and their symbols may serve your calls if you left anything dynamically resolvable.

Four combinations of two packages hiding or exporting their vendored symbols A two-by-two matrix. When both packages hide their symbols, each resolves inside itself and coexistence is guaranteed. When only you hide, you are safe and the other package may still collide with a third. When only they hide, you are exposed and must hide too. When neither hides, the import order decides which library both packages use, which is the failure this page is about. Hiding your own symbols is the only cell you control. they hide they export you hide you export guaranteed to coexist each module resolves inside itself import order is irrelevant you are safe their symbols cannot serve you they may still collide with a third you are exposed your symbols are reachable hide them — it is one flag import order decides both use whichever loaded first the failure this page is about you control one axis; moving to the top row removes your package from every collision you could participate in

The practical consequence is that hiding is unilaterally worth doing even when the other package is the noisier offender. It costs one linker flag and a four-line map file, it removes your package from the failure entirely, and it does not depend on anyone else changing anything.

Reporting the issue upstream is still worth the ten minutes. A short issue containing the export count (nm -D --defined-only | wc -l), the two import orders and the observed failure is usually enough; the fix on their side is the same one-flag change, and most maintainers accept it readily once the mechanism is demonstrated rather than asserted.

Static Linking as the Stronger Guarantee

Hiding symbols stops the export side of the problem. Where two copies of a library must genuinely coexist and each keeps its own process-wide state, absorbing the library statically is the stronger move.

Bundled shared library versus statically absorbed library With a bundled shared object, the library is a separate file the loader maps, it has a soname other objects can bind to, and its global state is per library instance. With static linking plus hidden visibility, the library's code is inside the extension, there is no soname for anything to bind to, and the state is genuinely private. The trade is wheel size and the loss of the ability to update the library without rebuilding. bundled shared object a separate .so inside the wheel has a soname others can bind to global state per library instance updatable without rebuilding you safe once hidden — but the object is still visible to the loader static + hidden the code lives inside the extension no soname exists to bind to state is genuinely private rebuild required to update it the strongest isolation available short of a separate process

For GEOS in particular the static route is usually straightforward, because the C API surface is small and the library builds a static archive without ceremony. For GDAL it is far less pleasant — the driver architecture, the plugin loading and the sheer size all argue against it — which is why most spatial wheels statically absorb the small libraries and ship GDAL as a bundled shared object with hidden visibility on the extension.

Pitfalls & Alternatives

Testing only one import order. A collision is deterministic given an order, so a suite that always imports in the same sequence passes reliably while users hit the other one. Both orders, in a loop.

Assuming RTLD_LOCAL is enough. CPython does load extensions locally, which keeps your module’s symbols private — but a bundled shared library loaded as a dependency carries its own linkage, and a library built with default visibility exports everything into whatever scope it lands in.

Adding a second symbol to the export map “for testing”. Every additional exported name is a surface something else can bind to. Test through the Python API; if a C-level test genuinely needs an entry point, build a separate test module rather than widening the shipped one.

Renaming symbols in C++ code with objcopy. Symbol prefixing works well for C and poorly for C++, where mangled names, RTTI comparisons and exception tables all assume consistency. GEOS is C++ internally, so prefer static linking with hidden visibility over renaming.

Frequently Asked Questions

Does this affect macOS and Windows too?

Less, and not zero. macOS uses two-level namespaces, so a dependency records which library each symbol came from and cross-binding is far less likely; Windows resolves DLLs by name globally, which is why repair tools rename bundled DLLs with a content hash. Hiding symbols is still worth doing on all three for consistency.

How do I know which package loaded first in a user’s environment?

You usually cannot, and that is the point: import order depends on their code, not yours. Designing so that order does not matter is cheaper than diagnosing which order they used.

Can I detect a collision at import time and warn?

To a degree — comparing the version your extension was compiled against with the version the loaded library reports catches the mismatch case, and it is a useful diagnostic to expose. It does not prevent the collision; it turns a mysterious crash into a clear message, which is worth the five lines.

Is there a downside to hiding symbols for a package that is not vendoring anything?

None worth mentioning. An extension with no vendored libraries exports its init function and little else anyway, so the flag is a no-op for correctness and still shrinks the dynamic symbol table slightly.

How do I write a regression test for this?

Import your package alongside the other spatial packages your users are likely to have, in both orders, and exercise one function from each. It is a handful of lines, it runs in a second, and it fails deterministically if visibility regresses — unlike the crash, which depends on what the two libraries do next.

Does static linking increase the wheel size much?

For GEOS, by a few megabytes — less than the shared object it replaces, because unreferenced code can be dropped at link time. For GDAL the calculation is different and usually argues against static linking, which is why the common arrangement is static small libraries and a bundled GDAL.

What should the upstream issue contain?

The export count from both packages, the two import orders with their outcomes, and the versions each side bundles. That is enough for a maintainer to reproduce it in minutes, and it distinguishes a demonstrated mechanism from a report that reads as speculation.