Reading objdump and readelf output for spatial wheels

This page answers one question: when you run objdump -p or readelf -d on a GDAL-linked extension, which lines actually matter and what does each one tell you about whether the wheel will import on someone else’s machine? It sits inside the Debugging Import Errors and Linker Failures section of the Geospatial C-Extension Fundamentals & ABI Architecture reference, and turns two walls of output into four questions with definite answers.

The four questions the dynamic section answers Four entries in an ELF dynamic section, each answering a question. SONAME says what this object calls itself, which matters only for libraries. NEEDED lists the libraries it will ask the loader for. RUNPATH says where it will ask the loader to look. And the versioned symbol references say which library versions it requires. Together they determine whether the import succeeds on a machine other than the one that built it. SONAME libgdal.so.34 what the object calls itself — present on libraries, absent on extension modules NEEDED libgdal.so.34, libproj.so.25, libc.so.6 what it will ask for — every entry must be satisfiable on the target RUNPATH $ORIGIN/../mypkg.libs where it will look — the entry that makes a wheel relocatable VERNEED GLIBC_2.28, GDAL_3.8 which versions it requires — this is what sets the platform tag

Context & Root Cause

Every shared object carries a dynamic section describing what it needs and where to find it. When an import fails, the answer is almost always visible there, but the raw output mixes those four load-bearing entries with dozens of internal ones — hash table offsets, relocation counts, flags — that tell you nothing about portability. Learning to filter is most of the skill.

The reason this matters more for spatial wheels than for ordinary extensions is the size of the dependency graph. A repaired GDAL wheel contains a dozen bundled libraries, each with its own dynamic section, and a wheel is only correct if all of them are consistent: every NEEDED entry resolvable inside the wheel, every object carrying a $ORIGIN-relative RUNPATH, and no version requirement above the platform tag’s floor. Checking the extension alone passes wheels that fail on a user’s machine, which is the failure the parent debugging import errors and linker failures guide catalogues.

Solution / Fix

This targets binutils 2.38+ on Linux; the macOS equivalents are noted at the end.

1. Unpack the wheel and look at the whole set

unzip -o dist/*.whl -d /tmp/w >/dev/null
find /tmp/w -name '*.so*' | sort

2. Read the dynamic section, filtered to the four entries that matter

for f in $(find /tmp/w -name '*.so*'); do
  echo "== $f"
  objdump -p "$f" | grep -E 'SONAME|NEEDED|RUNPATH|RPATH'
done

objdump -p is preferable to ldd here because it reads the file rather than executing the loader on it — no initialiser code runs, and the answer describes the object rather than this machine.

3. Check what version requirements the object carries

readelf -V /tmp/w/*/_ext*.so | sed -n '/Version needs/,/^$/p'
# each entry names a library and the symbol versions required from it

This is the section that decides the platform tag: the highest GLIBC_ version referenced anywhere in the wheel is the floor auditwheel will compute, as verifying wheel tags with auditwheel show explains.

4. Check the undefined symbols, filtered to the ones that are real problems

nm -D --undefined-only /tmp/w/*/_ext*.so | awk '{print $NF}' \
  | grep -vE '^(__cxa|__gmon|_ITM|GLIBC)' | sort -u | head -20

Verification

# 1. Every NEEDED entry is satisfied inside the wheel or by the base platform
BASE='libc\.so|libm\.so|libpthread|libdl|libstdc\+\+|libgcc_s|ld-linux'
for f in $(find /tmp/w -name '*.so*'); do
  objdump -p "$f" | awk '/NEEDED/{print $2}' | while read -r n; do
    [ -e "$(dirname "$f")/$n" ] || ls /tmp/w/*.libs/"$n" >/dev/null 2>&1 \
      || echo "$n" | grep -qE "$BASE" || echo "UNSATISFIED $n (from $f)"
  done
done
# expected: no output
# 2. Every bundled object has an $ORIGIN-relative RUNPATH
for f in $(find /tmp/w -name '*.so*'); do
  objdump -p "$f" | grep -q 'RUNPATH.*\$ORIGIN' || echo "NO RUNPATH: $f"
done
# expected: no output
# 3. The glibc floor matches the platform tag you intend
readelf -V $(find /tmp/w -name '*.so*') 2>/dev/null \
  | grep -oE 'GLIBC_[0-9]+\.[0-9]+' | sort -uV | tail -1
# expected: not higher than the tag on the filename

The first check is the most valuable of the three, because it is the one that models what the loader will actually do on a machine with nothing installed. A wheel that passes it will import in a bare container; one that fails it will import only where the missing library happens to exist.

Reading a Real Failure

Output is easier to interpret against a known-bad example. The three most common spatial failures each have a distinctive shape in the dynamic section.

Three broken wheels and what their dynamic sections look like Three examples. An unrepaired wheel has NEEDED entries for libgdal and libproj and no RUNPATH at all, so the loader searches the system and fails. A partially repaired wheel has a RUNPATH on the extension but not on the bundled libgdal, so libgdal cannot find libproj beside it. An over-linked wheel has NEEDED entries for libraries it never calls, which the linker flag as-needed would have removed and which each cost a load at import. unrepaired NEEDED libgdal.so.34 · NEEDED libproj.so.25 · (no RUNPATH) the loader searches the system and finds nothing on a bare container symptom: cannot open shared object file — fix: run the repair step partially repaired _ext.so: RUNPATH ok · libgdal.so.34: (no RUNPATH) the extension finds libgdal; libgdal cannot find libproj beside it symptom: works where PROJ is installed, fails elsewhere — fix: repair every object over-linked NEEDED libcurl.so.4 · NEEDED libssl.so.3 · (never called) libraries on the link line whose symbols are never referenced symptom: larger wheel, slower import — fix: link with --as-needed

The middle case is worth dwelling on because it is the one that produces the most confusing bug reports. The wheel works on every developer machine and on most CI runners, and fails specifically for users with minimal container images — a population that skews toward exactly the people who file precise, reproducible issues. The diagnostic is a single line: run the RUNPATH check over all objects rather than the extension alone.

The macOS and Windows Equivalents

The method transfers; only the tools change. Knowing the mapping saves relearning the whole approach per platform.

Equivalent inspection commands across the three platforms A translation table. Listing dependencies uses objdump -p on Linux, otool -L on macOS and dumpbin slash dependents on Windows. Inspecting the search path uses readelf -d on Linux, otool -l looking for LC_RPATH on macOS, and has no Windows equivalent because Windows records no search path. Listing exported symbols uses nm -D, nm -gU and dumpbin slash exports respectively. Checking architecture uses readelf -h, lipo -info and dumpbin slash headers. question Linux macOS Windows what does it need? objdump -p otool -L dumpbin /dependents where will it look? readelf -d otool -l | LC_RPATH nothing is recorded what does it export? nm -D nm -gU dumpbin /exports which architecture? readelf -h lipo -info dumpbin /headers the Windows gap in row two is the whole reason that platform needs an import-time shim rather than a recorded path and the reason the clean-environment import test carries more weight there than anywhere else

The empty cell in the second row is the substantive difference rather than a gap in the table. Because Windows records no search path in the binary, there is nothing to inspect and nothing to repair in the file itself — the equivalent work happens at import time in Python, as fixing “DLL load failed” for GDAL on Windows describes.

Pitfalls & Alternatives

Using ldd on an untrusted object. It works by invoking the dynamic loader, which can run initialiser code from the object being inspected. objdump -p reads the file and is the right default; keep ldd for confirming resolution on a machine you are debugging.

Reading nm without -D on a stripped library. The static symbol table is gone; the dynamic one remains. Without the flag you get “no symbols” and conclude something is wrong with the file rather than with the command.

Checking only the extension module. Every bundled library has its own dynamic section and its own dependencies. The checks in this page loop over all of them for exactly that reason.

Treating __cxa_* and GLIBC_* undefined symbols as errors. They are resolved by the target’s own runtimes and are expected in every correctly built wheel. Filtering them out is what makes the remaining list short enough to read.

Frequently Asked Questions

Why does nm -D list symbols the extension clearly defines?

Because it lists both defined and undefined entries by default. --defined-only and --undefined-only separate them, and for portability questions the undefined list is the interesting one — it is what the object expects someone else to supply.

Can these checks run on a machine that cannot execute the wheel?

Yes, and that is one of their advantages. objdump, readelf and nm read the file rather than running it, so an aarch64 wheel can be inspected on an x86_64 runner. Only the import test needs matching hardware or emulation.

What does an empty NEEDED list mean?

That everything was statically linked, which is legitimate and worth confirming rather than assuming. Check that the extension actually contains the code — a suspiciously small object with no dependencies usually means a link that quietly produced nothing.

Is there a single command that summarises all of this?

auditwheel show comes closest on Linux: it reports the computed tag and the external references in one output, and it reasons about policy rather than raw structure. Drop to these tools when it flags something and you need to know which object is responsible.

How do I trace which object pulled in an unexpected dependency?

Walk the graph: list each bundled library’s own NEEDED entries and find which one names the surprise. A dependency that appears in libgdal’s list and nowhere else came from GDAL’s configure, which points at the build flags rather than at your link line.

Is there a way to see the resolution rather than the declaration?

LD_DEBUG=libs prints every directory the loader tries and the path that satisfies each request, which is the run-time counterpart to the static inspection here. Use the static tools to understand what the wheel asks for, and that variable to see what a particular machine gives it.

Do these commands work on a wheel built for another platform?

The Linux tools read ELF and the macOS tools read Mach-O, so each inspects its own format regardless of which machine runs it — an aarch64 ELF is perfectly readable on an x86_64 host. What does not transfer is inspecting a Mach-O with readelf or an ELF with otool; those simply report an unrecognised format, which is occasionally mistaken for a corrupt file. Keeping both toolchains available on the machine that runs the validation job means one script can inspect every artifact the matrix produced, which is worth the small amount of setup. Installing both toolchains in the validation image is a two-line change that pays for itself the first time one script has to inspect every artifact in the matrix.