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.
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.
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.
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.
Related
- Symbol visibility and namespace isolation — the mechanism and the three isolation techniques compared.
- Hiding GEOS symbols with version scripts — the map file and the build wiring in detail.
- Thread safety and concurrency in GDAL bindings — why two copies of a library also mean two sets of global state.