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 section 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.
Anatomy of the Map File
A version script is a small language, and knowing its four moving parts removes most of the guesswork when a script does not behave as expected.
Three details in that anatomy cause most of the confusion. The symbol name must match the module name exactly, including the leading underscore convention your build uses: an extension imported as mypkg._geospatial_ext needs PyInit__geospatial_ext — two underscores, one from the naming convention and one from the module name. A typo here does not produce a link error; it produces a successful build and an ImportError: dynamic module does not define module export function at import.
The absence of a version tag is deliberate. Version scripts were designed for versioned system libraries, where each node carries a name like GLIBC_2.28 and the linker records symbol versions. A Python extension wants none of that, and an anonymous node — the leading { with no identifier — gives you the visibility partition without the version records. Adding a name works but writes version information into the object that serves no purpose and occasionally confuses tooling.
The local: * is not redundant with -fvisibility=hidden, even though they overlap. The compiler flag governs symbols from source files it compiles; the version script governs the final link, including symbols pulled in from static archives that were compiled elsewhere. A vendored GEOS built as a prebuilt .a was compiled without your flags, so the script is what actually hides it. Using both is the reliable combination: the flag keeps the object files small and the script guarantees the outcome.
What the Export Table Looks Like Either Way
The difference the script makes is visible in one number, and it is worth seeing the two tables side by side once so the nm output is unambiguous afterwards.
The counts vary with how much of GEOS your extension actually pulls in, but the shape does not: without a script the table is dominated by symbols nobody outside the module should ever call.
Proving It, and Keeping It Proved
The verification commands earlier in this page answer the question once. Making the answer durable means turning them into a test, because every mechanism that can hide symbols can also stop hiding them silently — a toolchain upgrade, a build-system refactor, a dependency that appends its own linker flags after yours.
# tests/test_exports.py — one assertion, runs in seconds, never goes stale
import glob
import subprocess
import sys
import pytest
@pytest.mark.skipif(sys.platform != "linux", reason="ELF-specific")
def test_only_pyinit_is_exported():
(ext,) = glob.glob("build/**/_geospatial_ext*.so", recursive=True)
out = subprocess.run(["nm", "-D", "--defined-only", ext],
capture_output=True, text=True, check=True).stdout
exported = [line.split()[-1] for line in out.splitlines() if line.strip()]
leaked = [s for s in exported if not s.startswith("PyInit_")]
assert not leaked, f"{len(leaked)} symbol(s) leak: {leaked[:10]}"
Run it against the built extension rather than the installed wheel if you can, because it then fails during the build job rather than after packaging. If your CI only has the wheel, unzip it first — the assertion is identical.
A useful companion check is the co-import test, which exercises the property the hiding exists to protect. Importing your extension alongside the other GEOS-vendoring packages your users are likely to have installed proves that two copies coexist. It costs one line and catches the case where hiding is correct on your side but a change in load order has exposed a different problem.
python -c "import mypkg._geospatial_ext, shapely.geometry, pyproj; print('coexist ok')"
python -c "import shapely.geometry, pyproj, mypkg._geospatial_ext; print('reverse order ok')"
Running both orders matters. A collision often manifests in one direction only, because whichever library loads first is the one whose symbols win — so a single-order test can pass on a broken build and fail for a user whose imports happen to be arranged differently.
Frequently Asked Questions
What is the macOS equivalent, and is it exactly the same?
-Wl,-exported_symbols_list,export.syms, where the file contains one entry per line with a leading underscore: _PyInit__geospatial_ext. The effect is comparable but the mechanism differs — macOS uses two-level namespaces, so cross-module binding is much less likely by default. Ship both anyway, so the build behaves identically everywhere and the export test can run on any platform you have.
Does hiding symbols break dlopen-based plugins?
Only if the plugin expects to resolve symbols from your extension, which is unusual for spatial packages. GDAL’s own driver plugins resolve against libgdal, not against your module, so hiding your exports does not affect them. If you genuinely provide a C API for others to link against, list those symbols explicitly in the global: clause — that is what the clause is for.
Will --no-undefined conflict with a version script?
No, they operate at different stages and complement each other: --no-undefined refuses to produce an object with unresolved references, while the version script decides which resolved symbols are exported. Using both means a build either produces a self-contained module exporting one name, or fails with a message.
Why does readelf --dyn-syms still show GEOS symbols after adding the script?
Almost always because the script reached the compiler rather than the linker, or reached an intermediate link rather than the final one. Confirm the flag appears in the final link command with make VERBOSE=1 or CMake’s --verbose; if it is absent there, the build system dropped it. The second most common cause is a build directory that was not cleaned, so you are inspecting an object produced before the change.
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.