Diagnosing undefined-symbol errors in spatial extensions
This page answers one question: your extension raises ImportError: undefined symbol: GEOSGeom_createLinearRing_r (or _Py_Dealloc, or GDALCreate) at import, so how do you tell whether the fault is an under-linked dependency, a wrong-version library, or a Stable-ABI violation — and fix the right one? It sits inside the Debugging Import Errors and Linker Failures section of the Geospatial C-Extension Fundamentals & ABI Architecture reference, and gives you the nm/readelf decision path and the three distinct fixes.
Context & Root Cause
An undefined symbol at import time means the dynamic loader successfully opened every library the extension NEEDED, bound as many symbols as it could, and then found one symbol that no loaded object defines. Unlike cannot open shared object file, the libraries are present — the contents are wrong. Three root causes produce this identical message, and the symbol’s name tells you which.
If the symbol belongs to a geospatial library (GEOSGeom_createLinearRing_r, GDALCreate, proj_create), the extension was under-linked: the header was included and the call compiled, but the library was never added to the link line, so the reference dangles. If the symbol is a CPython internal (_Py_Dealloc, _PyObject_GC_New) while the wheel is tagged abi3, it is a Stable-ABI violation — you called a symbol outside the limited set that C-API vs CPython ABI compatibility forbids. If the symbol exists but at a version the loaded library does not provide, the wrong version of a dependency resolved at load time. The method is to read the prefix, then apply that branch’s fix.
Solution / Fix
This targets binutils (nm, readelf) on Linux, GEOS 3.11+/GDAL 3.8+, and a wheel built to the abi3 contract.
1. List the undefined symbol and read its prefix
unzip -o dist/*.whl -d /tmp/w >/dev/null
nm -D /tmp/w/*.so | grep ' U ' # 'U' = undefined, needs another object
# e.g.: U GEOSGeom_createLinearRing_r
2a. Geospatial prefix → fix the link line
The reference is real but the library is missing from the link. Add it — through CMake this is a target_link_libraries entry:
find_package(GEOS 3.11 CONFIG REQUIRED)
target_link_libraries(_geospatial_ext PRIVATE GEOS::geos_c) # was omitted
Confirm the library now appears as NEEDED:
readelf -d /tmp/w/*.so | grep NEEDED | grep -i geos
# expected: 0x... (NEEDED) Shared library: [libgeos_c.so.1]
2b. _Py prefix → replace with a limited-API call
The macro Py_LIMITED_API silences the header but does not police your call sites. Swap the internal for its public equivalent (for example, use Py_DECREF rather than reaching into _Py_Dealloc) and rebuild. The audit of which symbols are inside the limited set is in building abi3 wheels for PyProj with Py_LIMITED_API — enforce -DPy_LIMITED_API=0x03090000 so the compiler rejects the escape at build time, not the loader at import.
2c. Versioned symbol → rebuild against the matching ABI
# The symbol exists but the loaded GEOS is too old
nm -D /tmp/w/*.libs/libgeos_c.so.1 | grep createLinearRing_r || echo "missing in bundled GEOS"
If the bundled library predates the symbol, pin the build and runtime GEOS to one minor version and rebuild, the resolution described in how to fix ABI version mismatch in GDAL wheels.
Verification
# After the fix, no geospatial or _Py symbol should remain undefined
nm -D /tmp/w/*.so | grep ' U ' | grep -vE 'GLIBC|__gmon|_ITM|__cxa'
# expected: empty
# Clean-container import proves all symbols now bind
docker run --rm -v "$PWD/dist:/d" python:3.12-slim \
bash -c "pip install /d/*.whl && python -c 'from osgeo import gdal, ogr; print(\"ok\")'"
# expected: ok
An empty nm filter and a printed ok confirm every reference resolves. A remaining U line names the exact symbol still dangling — re-run the prefix classification on it.
Pitfalls & Alternatives
Adding -Wl,--no-undefined and calling it fixed. That flag turns the failure into a link-time error, which is genuinely better — but it does not resolve the symbol; you still must add the correct library. Use it to catch under-linking early, then fix the link line.
Assuming order does not matter. With static archives, a library must appear on the link line after the object that references it, or the linker discards its symbols. If a geospatial symbol is still undefined after adding the library, check link order before anything else.
Treating an _Py symbol as a link problem. You cannot “add a library” to satisfy a CPython internal in an abi3 wheel — the whole point of the Stable ABI is that those symbols are off-limits. This branch is always a source fix, never a link fix. Symbol shadowing between two vendored copies is a distinct case handled in symbol visibility and namespace isolation.
Classifying a Symbol in Ten Seconds
The prefix rule can be applied mechanically, which is worth doing because the instinct to start editing the link line is strong and wrong two times in three. Every symbol you will meet in a spatial extension falls into one of five prefix families, and the family determines the fix before any tool is run.
The fourth row is the one that saves the most wasted effort: __cxa_* and GLIBC_* symbols appearing as undefined in nm -D output are normal. They are resolved by the target’s own C and C++ runtimes at load time, which is exactly what the manylinux policy expects, and filtering them out is why the verification command in this page’s earlier section pipes through grep -vE. A first-time reader who sees forty undefined symbols and panics is usually looking at thirty-eight legitimate runtime references and two real problems.
The fifth row is subtler and appears mostly in projects that have adopted hidden visibility. If a symbol defined in your own translation units shows up undefined, nothing is missing from the link — it was compiled, then hidden, and something outside the module tried to reach it. That is a visibility question rather than a linking one, and it is handled in symbol visibility and namespace isolation.
Where a Reference Is Supposed to Be Resolved
Every undefined symbol is a promise that some object loaded into the process will define it. Tracing where that promise was meant to be kept is what turns the nm output into a fix.
Read that picture backwards from the symbol you have: decide which box should have defined it, then check whether that box is present and current. A geospatial symbol whose provider is missing from NEEDED is a link-line fix; one whose provider is present but too old is a version fix; and a _Py symbol has no legitimate provider in an abi3 wheel at all, which is why that branch is always a source change.
Catching It at Build Time Instead
Every undefined-symbol import error is a link error that was allowed to escape, because the default behaviour when linking a shared object is to permit unresolved references and hope the loader finds them later. For a Python extension that hope is misplaced: the only thing that will be loaded alongside it is CPython itself and whatever the wheel bundles. Turning the permission off converts a class of user-visible import failures into a build failure with a precise message.
# Refuse to produce a shared object with unresolved references
LDFLAGS="-Wl,--no-undefined -Wl,--no-allow-shlib-undefined"
# The CMake form, applied to the extension target only
if(CMAKE_SYSTEM_NAME STREQUAL "Linux")
target_link_options(_geospatial_ext PRIVATE "-Wl,--no-undefined")
elseif(APPLE)
target_link_options(_geospatial_ext PRIVATE "-Wl,-undefined,error")
endif()
There is one exception to handle: CPython’s own API symbols are genuinely undefined in an extension module, because the interpreter provides them at load time. On Linux that is fine — --no-undefined complains only about symbols no shared library on the link line can supply, and linking against the Python library satisfies them. On macOS the historical idiom was -undefined dynamic_lookup, which disables the check entirely; the modern replacement is to link against the interpreter’s stub library so the check can stay on. If you must keep dynamic lookup, keep the Linux check strict and rely on it to catch the errors, since a symbol missing on one platform is almost always missing on both.
The complementary build-time control is compiling with -DPy_LIMITED_API set to your floor. It does not prevent every escape — a few internals remain reachable through macros — but it converts the majority of Stable-ABI violations from a runtime _Py* undefined symbol into a compile error naming the file and line. Combined with --no-undefined, the two together mean that a wheel which builds is very likely to import, which is the property you want from a matrix that produces artifacts nobody executes until release day.
Frequently Asked Questions
Why does the symbol appear undefined when I can see it in the library?
Check which symbol table you are reading. nm without -D shows the static table, which a stripped shared object may not have; nm -D shows the dynamic table, which is what the loader uses. A symbol present in the static table but absent from the dynamic one has been hidden — by -fvisibility=hidden, a version script, or a static link that absorbed it — and no amount of link-line editing will expose it.
Does link order still matter with shared libraries?
Less than with static archives, but it is not irrelevant. With static archives the linker discards any member whose symbols have not yet been requested, so a library placed before the object that needs it contributes nothing — the classic cause of a symbol that is undefined despite the library being on the command line. Shared libraries record their full symbol table regardless of position, so ordering problems there are rarer and usually indicate two libraries defining the same name.
Can I ignore an undefined symbol that never gets called?
Not safely. Lazy binding means the process may start and run for a long time before hitting it, so what you gain is a crash later rather than an error now — and in a library that runs inside other people’s data pipelines, later means in production. If the symbol is genuinely unreachable, remove the code path or the dependency so the reference disappears.
The symbol is GEOSGeom_createLinearRing_r — why the _r suffix?
Because GEOS’s reentrant API takes an explicit context handle as its first argument, and the non-reentrant variants are deprecated. Seeing the _r form undefined while the non-_r form resolves usually means headers and library disagree on version: the header declared the reentrant entry point, the linked library predates it. Pin both to the same minor version and rebuild.
Related
- Debugging Import Errors and Linker Failures — the parent guide classifying this against missing-SONAME and version-mismatch errors.
- C-API vs CPython ABI compatibility — why a
_Pyundefined symbol is a Stable-ABI violation, and how to prevent it at compile time. - Symbol visibility and namespace isolation — when the symbol is defined twice and the wrong one binds.