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.

A version script filtering the dynamic symbol table down to one entry The extension object file contains the PyInit function plus hundreds of vendored GEOS symbols. The linker applies the version script, whose global clause names only PyInit and whose local wildcard hides the rest, producing a shipped shared object that exposes a single dynamic symbol. object file PyInit__ext GEOSGeomCreate GEOSCoordSeq_... +300 GEOS symbols version script global: PyInit__ext; local: *; applied by ld shipped .so PyInit__ext (1 dynamic symbol) GEOS hidden

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
};

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.