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.

Decision tree from import error text to the diagnostic tool and fix An import failure branches by its error text. "cannot open shared object file" points to a missing SONAME, diagnosed with ldd and fixed by auditwheel repair. "undefined symbol" points to a missing or wrong-ABI dependency, diagnosed with nm and readelf and fixed by correcting the link line. "version not found" points to loader precedence, diagnosed with readelf -d RPATH and fixed by disabling new dtags. import fails read the error class cannot open shared object missing SONAME → ldd undefined symbol wrong / missing dep ABI → nm · readelf version `PROJ_9.4' not found loader precedence → readelf -d auditwheel repair bundle the .so fix the link line match dependency ABI disable new dtags $ORIGIN RPATH wins

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) and patchelf on Linux; otool and install_name_tool on macOS (ship with Xcode command-line tools).
  • auditwheel 6.x and delocate 0.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

  1. 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'"
    
  2. Capture the exact error text. Copy the last line verbatim; the SONAME version (libgdal.so.34) and symbol name are the entire clue.

  3. 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
    
  4. 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. ldd becomes otool -L, RPATH inspection becomes otool -l | grep -A2 LC_RPATH, and repair is delocate-wheel. The three classes are identical; only the tools rename.
  • auditwheel show is a fast pre-flight. Before hand-inspecting, auditwheel show wheel.whl lists 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 aarch64 wheel 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.

Further Reading

  • auditwheel documentation (pypa/auditwheel) on the manylinux policy and repair internals.