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.
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.
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.
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.
Related
- Debugging import errors and linker failures — the parent guide that classifies errors before you reach for these tools.
- Managing shared library paths in manylinux — what a correct
RUNPATHlooks like and why every object needs one. - Verifying wheel tags with auditwheel show — the policy-level summary of the same information.