Hiding GEOS symbols with version scripts
This page answers one question: how do you write and wire a linker version script so a GEOS-vendoring extension exports only its PyInit_ function and keeps every GEOS* symbol private, so it can coexist with shapely or another GEOS-vendoring wheel in the same interpreter? It sits inside the Symbol Visibility and Namespace Isolation cluster of the Geospatial C-Extension Fundamentals & ABI Architecture reference, and gives you the exact export.map, the CMake and setuptools wiring, and the readelf proof.
Context & Root Cause
A version script is a linker input that partitions a shared object’s symbols into global (exported, visible to dlopen) and local (hidden, resolvable only within the object). For a Python extension the correct partition is trivial: one symbol global — the PyInit_ entry point the interpreter calls — and everything else local. Without it, a statically-linked GEOS contributes its several hundred GEOS* functions to the extension’s dynamic symbol table, and because CPython extensions share one process, those symbols can bind across module boundaries.
The concrete failure is that shapely (which vendors GEOS) and your extension (which vendors a different GEOS) both export GEOSGeom_createLinearRing_r; the second module loaded reuses the first’s function while passing its own struct layout, and the mismatch crashes. The version script removes the collision surface entirely by never exporting the GEOS symbols in the first place. It is the enforcement mechanism for the visibility policy described in the parent Symbol Visibility and Namespace Isolation guide.
Solution / Fix
This targets GNU ld/lld on Linux (macOS uses -exported_symbols_list) and an extension that statically links GEOS.
1. Write the version script
# export.map
{
global:
PyInit__geospatial_ext; # the sole exported symbol
local:
*; # hide GEOS, PROJ, and every other symbol
};
2. Wire it into the link
For a raw setuptools build:
# setup.py
Extension(
"_geospatial_ext",
sources=["src/_geospatial_ext.c"],
extra_compile_args=["-fvisibility=hidden"],
extra_link_args=["-Wl,--version-script=export.map"],
py_limited_api=True,
)
For the scikit-build-core backend, attach it in CMake:
target_link_options(_geospatial_ext PRIVATE
"-Wl,--version-script=${CMAKE_CURRENT_SOURCE_DIR}/export.map")
set_target_properties(_geospatial_ext PROPERTIES C_VISIBILITY_PRESET hidden)
3. Keep the init symbol visible
Because -fvisibility=hidden hides everything by default, the init function needs an explicit export attribute so the global: clause has something to match:
__attribute__((visibility("default")))
PyMODINIT_FUNC PyInit__geospatial_ext(void) { /* ... */ }
Verification
# 1. Exactly one exported symbol, and it is the init function
readelf --dyn-syms build/_geospatial_ext*.so | awk '$4=="FUNC" && $5=="GLOBAL"' | grep -c PyInit
# expected: 1
# 2. No GEOS symbol is globally exported
readelf --dyn-syms build/_geospatial_ext*.so | grep GEOS | grep -i default
# expected: empty
# 3. Co-import with shapely (also GEOS-backed) must not crash
python -c "import _geospatial_ext, shapely.geometry; print('ok')"
# expected: ok
A single PyInit export, no default-visibility GEOS symbols, and a clean co-import confirm the isolation holds. If step 2 lists GEOS symbols, the version script was not applied to the final link — check that it is passed via -Wl, and points at the right path.
Pitfalls & Alternatives
Naming the wrong init symbol. The global: entry must exactly match PyInit_<modulename>; a typo hides the init function too and yields dynamic module does not define module export function. Copy the name from the source, do not retype it.
Applying the script to a compile step. --version-script is a linker option; passing it as a compile flag is silently dropped. Confirm it reaches ld by inspecting the exported symbols, never by trusting the build log.
Exporting a helper “for testing.” Adding a second symbol to global: reopens the collision surface. Keep the export list at one symbol and test through the Python API instead. When a symbol you need internally goes missing after tightening the script, that is the inverse problem in diagnosing undefined-symbol errors.
Related
- Symbol Visibility and Namespace Isolation — the parent guide on
-fvisibility=hidden,RTLD_LOCAL, and why the export list must be one symbol. - Diagnosing undefined-symbol errors in spatial extensions — what to do when isolation hides a symbol you needed.
- Securely compiling spatial C-extensions — the hardening flags that pair with hidden visibility.