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 cluster of the Geospatial C-Extension Fundamentals & ABI Architecture reference, and gives you the nm/readelf decision path and the three distinct fixes.

Classifying an undefined symbol by its prefix An undefined symbol is classified by name. A GEOS or GDAL or PROJ prefixed symbol means the extension was under-linked and the fix is to add the library to the link line. A Py-prefixed symbol outside the limited set means a Stable ABI violation and the fix is to use the limited API equivalent. A symbol present but at a different version means a wrong library was resolved and the fix is to rebuild against the matching ABI. nm -D shows U symbol classify by prefix GEOS* / GDAL* / proj_* under-linked _Py* outside limited set Stable-ABI violation present, different version wrong library resolved add lib to link line use limited-API call rebuild vs matching ABI

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

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.