Platform-Specific ABI Quirks for Geospatial Wheels
A geospatial wheel that imports flawlessly on Linux can still fail on macOS with a code-signing error or on Windows with DLL load failed, because each platform enforces its own binary-interface rules on top of the CPython ABI: macOS has fat universal2 binaries, @rpath install names, and notarization; Windows has no RPATH at all and a search order that ignores the directory next to your .pyd. This guide sits under the Geospatial C-Extension Fundamentals & ABI Architecture reference and maps the per-platform divergences that the Linux-centric shared library path resolution rules do not cover. It targets GDAL 3.8+, PROJ 9.3+, delocate 0.11+ and delvewheel 1.x, cibuildwheel 3.0+, and macOS 12+/Windows Server 2022 runners.
Prerequisites & Environment
- macOS: Xcode command-line tools (
otool,install_name_tool,codesign),delocate0.11+, and an Apple Silicon or Intel runner. Foruniversal2, botharm64andx86_64slices of every native library. - Windows: Visual Studio 2022 build tools (
dumpbin),delvewheel1.x, and a GDAL/PROJ build compiled with the same MSVC toolset as the extension. cibuildwheel3.0+ to orchestrate all three from one matrix, as detailed in cibuildwheel vs manual Docker matrix for GDAL wheels.
# macOS: inspect an extension's install names
otool -L build/_geospatial_ext.cpython-312-darwin.so
# Windows (Dev Prompt): inspect a .pyd's DLL imports
dumpbin /dependents build\_geospatial_ext.cp312-win_amd64.pyd
Core Configuration
The mechanisms differ, but each platform has one “repair” tool that makes the wheel relocatable:
| Concern | Linux | macOS | Windows |
|---|---|---|---|
| Relocation token | $ORIGIN (RPATH) |
@loader_path (install name) |
none — copy DLLs adjacent |
| Repair tool | auditwheel |
delocate |
delvewheel |
| Extra gate | platform tag policy | code signature / notarization | DLL name mangling |
| Diagnostic | readelf -d |
otool -l |
dumpbin /dependents |
macOS install names are absolute by default (/opt/gdal/lib/libgdal.dylib); delocate rewrites them to @loader_path-relative and copies the .dylibs into the wheel, the direct analogue of auditwheel’s $ORIGIN rewrite. Windows has no equivalent of RPATH, so delvewheel instead copies the DLLs into the package and mangles their names to avoid collisions, then the package must call os.add_dll_directory at import.
Step-by-Step Implementation
-
macOS — build the slices you promise in the tag. A wheel tagged
universal2must containarm64andx86_64code in every binary; the full build is in building universal2 GDAL wheels for Apple Silicon. -
macOS — delocate then verify signatures:
delocate-wheel -w repaired/ -v dist/*.whl codesign --verify --deep repaired/*.whl 2>&1 || echo "sign before notarizing" -
Windows — delvewheel repair:
delvewheel repair -w repaired dist\*.whl -
Windows — ensure the import shim is present. The package
__init__.pymust register the bundled DLL directory, the fix detailed in fixing “DLL load failed” for GDAL on Windows.
Verification
# macOS: every install name must be @loader_path or a system framework
otool -L repaired/_geospatial_ext*.so | grep -v '@loader_path' | grep -vE '/usr/lib|/System'
# expected: empty (no absolute non-system paths)
:: Windows: the pyd's GDAL import must resolve to a bundled, mangled DLL
dumpbin /dependents repaired\_geospatial_ext*.pyd | findstr /i gdal
:: expected: gdal-<hash>.dll (a mangled name, present in the package)
# Both: clean-machine import is the acceptance test
python -c "from osgeo import gdal; print(gdal.__version__)"
The Same Wheel, Three Repair Pipelines
Seen end to end, the three platforms run the same five steps with different tools, and the differences are narrower than the vocabulary suggests. Laying them side by side makes it obvious which step is missing when a platform misbehaves.
Those two exceptions in the last line are where nearly all platform-specific bugs live: a macOS wheel that was modified after signing, and a Windows wheel whose import-time shim never ran.
Optimization & Edge Cases
universal2doubles build time and size. Two architecture slices means two native builds; if your users are entirely on Apple Silicon, ship a singlearm64wheel and skip the fat binary.- Windows DLL mangling defeats manual
LoadLibrary. If code callsLoadLibrary("gdal.dll")directly,delvewheel’s renamedgdal-<hash>.dllwill not be found — go through the package, never a hard-coded name. - macOS notarization rejects unsigned nested dylibs.
delocatecopies unsigned.dylibs; sign them before submitting for notarization or the whole wheel is refused.
Troubleshooting
ImportError: dlopen(...): code signature invalid on Apple Silicon. Rewriting an install name with install_name_tool invalidates the signature; re-sign after every modification, and let delocate handle the ordering.
ImportError: DLL load failed while importing _gdal: The specified module could not be found. The DLL search order never looked next to the .pyd. Register the directory with os.add_dll_directory, covered fully in the Windows deep-dive.
incompatible architecture (have 'arm64', need 'x86_64'). A universal2 wheel is missing a slice in one bundled library. Rebuild that dependency with -arch arm64 -arch x86_64 or lipo-merge the two.
Three Loaders, Three Different Questions
The reason a wheel needs three repair tools is that the three platforms do not merely spell relocation differently — they ask different questions at load time, in a different order, with different fallbacks. Understanding the order is what turns a mysterious ImportError into a two-minute fix.
On Linux the dynamic linker resolves a DT_NEEDED soname by consulting, in order, the DT_RPATH baked into the object (deprecated but still honoured), LD_LIBRARY_PATH, the DT_RUNPATH, the cache in /etc/ld.so.cache, and finally the default directories. auditwheel works by writing a RUNPATH of $ORIGIN/../mypkg.libs into every bundled object, so the search succeeds inside the wheel before it ever reaches the system. The failure mode when this goes wrong is OSError: libproj.so.25: cannot open shared object file: No such file or directory — a clear message naming the missing soname.
On macOS the linker does not search by soname at all. Every dependency is recorded as an install name, which is a path, and the loader resolves that path with three expansion tokens: @executable_path, @loader_path and @rpath. delocate rewrites absolute install names to @loader_path-relative ones and copies the .dylibs alongside. Because the recorded value is a path rather than a name, a wrong one produces ImageNotFound naming a path that never existed on the user’s machine — often revealing the build machine’s directory layout in the error text, which is a useful tell.
On Windows there is no path recorded at all: the import table names gdal.dll and the loader searches the application directory, the system directories, and then PATH. Nothing looks next to the .pyd, which is why delvewheel both copies the DLLs into the package and the package must call os.add_dll_directory before importing the extension. The error, DLL load failed while importing _gdal: The specified module could not be found, is famously unhelpful because it names the extension rather than the DLL that was actually missing.
That last point generalises into a rule worth stating plainly: on every platform, a wheel can appear to work because a later row in the search order matched something the build machine happened to have. The Linux developer with a system libproj, the macOS developer with Homebrew’s GDAL, the Windows developer with OSGeo4W on PATH — all three will import a broken wheel without error. Only an environment stripped of geospatial libraries tests what the wheel actually contains.
Code Signing, Notarization and the Windows Equivalent
macOS adds a second enforcement layer that has no Linux analogue: the kernel refuses to load a binary whose signature does not match its contents. Every tool that rewrites an install name — install_name_tool, delocate, a manual lipo merge — invalidates the signature of the object it modified, because the signature covers the bytes that were just changed. The result is code signature invalid at import, on Apple Silicon in particular, where the requirement is enforced far more strictly than on Intel.
The ordering that works is: build, merge architectures, rewrite install names, then sign, then notarize. Signing before the rewrite wastes the signature; notarizing before signing the nested .dylibs fails the submission, because notarization inspects every Mach-O object in the bundle, not just the top-level one. delocate handles the common case correctly on its own, and the reason to know the ordering anyway is that any custom post-processing step — stripping symbols, for instance — silently re-invalidates everything downstream of it.
# Sign every nested dylib, then the extension, then verify before notarizing
find repaired -name '*.dylib' -exec codesign --force --sign - --timestamp=none {} \;
codesign --force --sign - repaired/_geospatial_ext*.so
codesign --verify --verbose repaired/_geospatial_ext*.so
Ad-hoc signing (--sign -) is sufficient for a wheel installed by pip, because the wheel is not a distributed application bundle and Gatekeeper is not involved in the same way. A Developer ID signature and full notarization become necessary only when the wheel ships inside an installer or an app bundle — a case worth knowing about because it changes the build from something CI can do unattended into something requiring credentials and a submission round-trip.
Windows has no equivalent requirement for pip-installed wheels, but it has an equivalent trap: DLL name mangling. delvewheel renames each bundled DLL to something like gdal-3a7f21c9.dll specifically so that two packages bundling different GDAL builds do not collide in the process’s module table, since Windows resolves DLLs by name globally rather than per-package. The consequence is that any code path calling ctypes.CDLL("gdal.dll") or LoadLibrary("gdal") directly will fail on a repaired wheel, even though the DLL is right there. Access the library through the package’s own extension module, or ask delvewheel to skip mangling for the specific DLL and accept the collision risk.
The macOS analogue of that collision problem is two-level namespaces, which mostly saves you: a .dylib records which library each symbol came from, so two GDALs can coexist without symbol interposition in the way they would on Linux. “Mostly” is doing work in that sentence — global constructors, atexit handlers and any library holding process-wide state (PROJ’s context cache, GDAL’s driver registry) still run twice, which produces double registration warnings and, occasionally, a crash on interpreter shutdown.
A Per-Platform Acceptance Checklist
Because the three platforms fail differently, a single “does it import?” check is not enough to accept a release. What follows is the shortest set of assertions that, taken together, prove a spatial wheel is genuinely self-contained on each platform. Each one exists because a real class of shipped bug slipped past the checks around it.
On Linux, assert three things. First, the platform tag is a manylinux/musllinux tag rather than linux_x86_64, which proves the repair step ran at all. Second, the external-reference list contains only base-platform libraries — the presence of libcurl, libsqlite3 or libtiff in that list means the wheel expects the user’s machine to supply them. Third, readelf -d on each bundled object shows a RUNPATH beginning with $ORIGIN, which proves the objects can find each other after the package is installed anywhere.
On macOS, assert four. Every install name in every bundled object is either @loader_path-relative or a system framework path. The architecture slices present in the file match the platform tag — a wheel tagged universal2 containing only arm64 code installs cleanly and then fails at import on Intel with incompatible architecture. Every Mach-O object passes codesign --verify, since a rewritten install name invalidates a signature. And MACOSX_DEPLOYMENT_TARGET is no newer than the oldest macOS you intend to support, because that value is what the tag promises.
On Windows, assert three. The .pyd’s import table resolves to DLLs present inside the package, not to bare names the loader will look for on PATH. The package registers its DLL directory at import time — a check as simple as importing with an empty PATH in a clean container. And the Visual C++ runtime the extension needs is either present on every supported Windows version or shipped alongside, since a wheel built with a newer toolset can require a redistributable the user has never installed.
# Linux: the three assertions, as a script that exits non-zero on failure
set -e
auditwheel show dist/*.whl | grep -qE 'manylinux_[0-9]+_[0-9]+|musllinux'
auditwheel show dist/*.whl | grep -qvE 'libcurl|libsqlite3|libtiff'
python - <<'PY'
import glob, subprocess, sys
for so in glob.glob('mypkg/**/*.so', recursive=True):
out = subprocess.run(['readelf', '-d', so], capture_output=True, text=True).stdout
if 'RUNPATH' in out and '$ORIGIN' not in out:
sys.exit(f'{so}: RUNPATH does not start with $ORIGIN')
PY
Run all of them in CI, on the artifact that will be uploaded, and record the output alongside the wheels. Between them they close every failure mode described above, and they take seconds — far less than the time spent diagnosing a single user report that begins “it installs but it will not import”.
Frequently Asked Questions
Should I ship universal2 or two separate macOS wheels?
Two separate wheels, unless a specific consumer requires a fat binary. Separate arm64 and x86_64 wheels halve the download size for every user, build in parallel rather than sequentially, and let you drop the Intel slice on its own schedule. universal2 earns its cost mainly when the wheel is embedded in an application bundle that must itself be universal.
Why does my Windows wheel work locally but fail for users?
Almost always because your machine has GDAL on PATH — from OSGeo4W, QGIS, or a conda environment — and the loader found it through a later row in the search order. Test in a container or a clean virtual machine with no GIS software installed; that is the only environment that exercises the DLLs your wheel actually bundled.
Can one cibuildwheel configuration drive all three repairs?
Yes, and it is the recommended shape. cibuildwheel invokes auditwheel, delocate and delvewheel per platform with sensible defaults, so the per-platform knowledge lives in a handful of repair-wheel-command overrides rather than in three hand-written pipelines. Override only where you must — for example to pass --add-path to delvewheel when the DLLs live outside the wheel tree.
What is the macOS deployment target and how does it differ from the glibc floor?
MACOSX_DEPLOYMENT_TARGET is the oldest macOS version the binary promises to run on, and it is baked into the wheel’s platform tag exactly as the glibc version is on Linux. The difference is that it is a build-time declaration rather than a computed property: setting it lower than the SDK you built against does not by itself guarantee compatibility, because a call to a newer API will still be emitted and will fail at runtime on the older system.
Related
- Building universal2 GDAL wheels for Apple Silicon — the fat-binary build and
delocatefusing for macOS. - Fixing “DLL load failed” for GDAL on Windows — the DLL search order and
add_dll_directoryshim. - Shared library path resolution — the Linux
$ORIGIN/RPATH baseline these platforms diverge from.
Do I need a different repair strategy for Linux ARM wheels?
No — auditwheel treats aarch64 exactly as it treats x86_64, with the same $ORIGIN rewriting and the same tag policy. What differs is everything around it: the base image is a different manylinux variant, the build is frequently emulated and therefore slow, and a surprising number of upstream build scripts still assume x86_64 in their configure logic. The repair is the easy part; getting a correct native build to hand to it is the work.
How should I handle a user who has both my wheel and a system GDAL installed?
Make the wheel’s own libraries win, deterministically. On Linux that means RUNPATH rather than RPATH semantics pointing inside the package; on macOS it means @loader_path; on Windows it means registering the package’s directory before importing the extension. Then document the behaviour, because a user who deliberately wants the system GDAL — typically to get a proprietary driver — needs to know that installing your wheel will shadow it, and that the supported way to combine them is a source build rather than a wheel.
Further Reading
delvewheelanddelocateproject READMEs for the per-platform repair internals.