Debugging Import Errors and Linker Failures in Spatial Wheels
When a geospatial wheel installs cleanly but blows up on import, the traceback is almost always a symptom of a link that was resolved wrong at build time — a missing SONAME, an undefined symbol, or a loader that found the host’s library instead of the bundled one. This guide sits under the Geospatial C-Extension Fundamentals & ABI Architecture reference and turns the opaque ImportError/OSError surface into a systematic diagnostic procedure: read the error class, inspect the binary with the right tool, and map the finding to a fix. It targets GDAL 3.6–3.9, PROJ 9.2+, GEOS 3.11+, auditwheel 6.x on Linux and delocate 0.11+ on macOS, and assumes wheels built to the ABI contract described in C-API vs CPython ABI compatibility.
Prerequisites & Environment
Effective binary debugging needs the target’s own inspection tools, not the host’s guesses. Install and confirm the following before diagnosing:
binutils(nm,readelf,objdump) andpatchelfon Linux;otoolandinstall_name_toolon macOS (ship with Xcode command-line tools).auditwheel6.x anddelocate0.11+ — both a repair tool and the best inspector of what a wheel actually links.- A clean runtime image,
python:3.12-slim, with none of your build dependencies installed. Every import bug reproduces there and hides on the build box. - The exact wheel under test, unzipped:
unzip -o dist/*.whl -d /tmp/w. You debug the artifact, never the source tree.
# Confirm the toolchain sees the wheel's extension module
unzip -o dist/*.whl -d /tmp/w >/dev/null
find /tmp/w -name '*.so' -o -name '*.pyd' -o -name '*.dylib'
Core Configuration: three error classes, three tools
Every geospatial import failure sorts into one of three classes, and each class has one authoritative diagnostic:
| Error text (verbatim) | Class | Tool that confirms it |
|---|---|---|
cannot open shared object file: No such file or directory |
A dependency’s SONAME is not on the loader’s path | ldd extension.so shows it as => not found |
undefined symbol: <name> |
Linked against the wrong dependency, or under-linked | nm -D extension.so shows the symbol as U (undefined) |
version `…` not found |
The wrong copy of a bundled library won at load time | readelf -d extension.so shows RPATH vs RUNPATH |
The distinction matters because the fixes diverge completely: a missing SONAME is a repair problem, an undefined symbol is a link-line problem, and a version mismatch is a loader-precedence problem. Guessing wastes the most time; reading the class first is the whole method. The deep dives are fixing “libgdal.so: cannot open shared object file” and diagnosing undefined-symbol errors in spatial extensions.
Step-by-Step Implementation
-
Reproduce in a clean container. If it imports on the build box, you are debugging the wrong environment:
docker run --rm -v "$PWD/dist:/d" python:3.12-slim \ bash -c "pip install /d/*.whl && python -c 'from osgeo import gdal'" -
Capture the exact error text. Copy the last line verbatim; the SONAME version (
libgdal.so.34) and symbol name are the entire clue. -
Classify with the table above, then run only that class’s tool:
ldd /tmp/w/*.so | grep -i 'not found' # class A nm -D /tmp/w/*.so | grep ' U ' # class B readelf -d /tmp/w/*.so | grep -E 'RPATH|RUNPATH|NEEDED' # class C -
Apply the class-specific fix (repair / relink / dtags), rebuild, and return to step 1. Never fix two classes at once — you lose the signal.
Verification
A correctly linked geospatial wheel passes all three of these in a clean container:
# No unresolved dependencies
ldd /tmp/w/*.so | grep -c 'not found' # expected: 0
# No undefined symbols outside the permitted base libc set
nm -D /tmp/w/*.so | grep ' U ' | grep -vE 'GLIBC|__gmon|_ITM' # expected: empty
# Bundled libraries resolve via $ORIGIN, not the host
readelf -d /tmp/w/*.so | grep RUNPATH # expected: [$ORIGIN/../name.libs]
The acceptance test is unchanged from the parent reference: python -c "from osgeo import gdal; print(gdal.__version__)" in a base image with no GDAL installed. If that prints a version, the link graph is sound end to end.
Optimization & Edge Cases
- macOS uses different verbs.
lddbecomesotool -L, RPATH inspection becomesotool -l | grep -A2 LC_RPATH, and repair isdelocate-wheel. The three classes are identical; only the tools rename. auditwheel showis a fast pre-flight. Before hand-inspecting,auditwheel show wheel.whllists external references and the platform tag it would assign — a lower tag than expected is an early warning that a link is wrong.- Emulated tests can lie. An
aarch64wheel imported under QEMU may resolve a host library that will not exist on real hardware; validate on native arches, as cross-compiler toolchain setup explains.
Troubleshooting
ldd: exited with unknown exit status or a hang. ldd executes the object to resolve symbols and can run init code; prefer objdump -p extension.so | grep NEEDED for a static, side-effect-free list of dependencies.
nm: no symbols. The extension was stripped. Inspect the dynamic table with nm -D, which survives stripping, or rebuild without -s for a debugging pass.
A symbol shows defined but import still fails. Two copies of the same library are loaded and one shadows the other — a symbol-visibility problem covered in symbol visibility and namespace isolation.
Related
- Fixing “libgdal.so: cannot open shared object file” — the missing-SONAME class, end to end.
- Diagnosing undefined-symbol errors in spatial extensions — the wrong-link-line class, with
nmwalkthroughs. - Shared library path resolution — the loader-precedence rules behind the version-mismatch class.
- C-API vs CPython ABI compatibility — why a Stable-ABI violation surfaces as an
undefined symbolat import.
Further Reading
auditwheeldocumentation (pypa/auditwheel) on the manylinux policy and repair internals.