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.

How the three platforms locate a bundled native library at import Three columns compare runtime library resolution. Linux uses an RPATH of $ORIGIN and the repair tool auditwheel. macOS uses install names rewritten to @loader_path by delocate plus code signing. Windows has no RPATH, relies on the DLL search order, and uses delvewheel to mangle and copy DLLs next to the pyd. Linux RPATH mechanism $ORIGIN/../pkg.libs repair: auditwheel tag: manylinux_2_28 loader reads RUNPATH macOS install-name mechanism @loader_path/../libs repair: delocate universal2 + notarize dyld reads LC_RPATH Windows no RPATH at all DLL search order repair: delvewheel mangle + copy DLLs add_dll_directory

Prerequisites & Environment

  • macOS: Xcode command-line tools (otool, install_name_tool, codesign), delocate 0.11+, and an Apple Silicon or Intel runner. For universal2, both arm64 and x86_64 slices of every native library.
  • Windows: Visual Studio 2022 build tools (dumpbin), delvewheel 1.x, and a GDAL/PROJ build compiled with the same MSVC toolset as the extension.
  • cibuildwheel 3.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

  1. macOS — build the slices you promise in the tag. A wheel tagged universal2 must contain arm64 and x86_64 code in every binary; the full build is in building universal2 GDAL wheels for Apple Silicon.

  2. macOS — delocate then verify signatures:

    delocate-wheel -w repaired/ -v dist/*.whl
    codesign --verify --deep repaired/*.whl 2>&1 || echo "sign before notarizing"
    
  3. Windows — delvewheel repair:

    delvewheel repair -w repaired dist\*.whl
    
  4. Windows — ensure the import shim is present. The package __init__.py must 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.

Build, repair and verify steps on Linux, macOS and Windows Three parallel pipelines with five steps each. All three compile and link, then copy the native libraries into the wheel, then rewrite how those libraries are located — RUNPATH on Linux, install names on macOS, and name mangling plus a loader shim on Windows — then recompute or accept the platform tag, and finally verify with a clean-environment import. Only macOS has an additional re-signing step, and only Windows requires code inside the package at import time. 1 · compile + link 2 · copy libs in 3 · rewrite lookup 4 · platform tag 5 · verify Linux gcc, ld auditwheel repair RUNPATH = $ORIGIN/… computed from glibc syms import in slim container macOS clang, ld64 delocate-wheel install name = @loader_path from deployment target import native + Rosetta Windows MSVC, link.exe delvewheel repair mangle + add_dll_directory code runs at import fixed: win_amd64 import with minimal PATH macOS adds a sixth step nobody else has — re-sign after every rewrite; Windows is the only platform where step 3 runs at import rather than at build

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

  • universal2 doubles build time and size. Two architecture slices means two native builds; if your users are entirely on Apple Silicon, ship a single arm64 wheel and skip the fat binary.
  • Windows DLL mangling defeats manual LoadLibrary. If code calls LoadLibrary("gdal.dll") directly, delvewheel’s renamed gdal-<hash>.dll will not be found — go through the package, never a hard-coded name.
  • macOS notarization rejects unsigned nested dylibs. delocate copies 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.

What each platform's loader searches, in order, when an extension is imported Three parallel search sequences. Linux resolves a soname through RPATH, LD_LIBRARY_PATH, RUNPATH, the loader cache and the default directories. macOS resolves a recorded install-name path, expanding loader_path, executable_path and rpath tokens. Windows resolves a bare DLL name through the application directory, directories added by add_dll_directory, the system directories and PATH — never the directory containing the extension module. Linux · by soname DT_RPATH (legacy) LD_LIBRARY_PATH DT_RUNPATH = $ORIGIN/… /etc/ld.so.cache /lib, /usr/lib auditwheel writes the third row so the search stops inside the wheel macOS · by install name absolute path as recorded @loader_path/… expansion @executable_path/… @rpath entries in LC_RPATH dyld shared cache delocate rewrites row one into row two and re-signs the modified binaries Windows · by bare name application directory os.add_dll_directory() System32, Windows directories on PATH never: next to the .pyd delvewheel copies and renames DLLs; the package must register the directory the repair tool's whole job is to make the highlighted row succeed before any later row is consulted a wheel that works only because a later row matched is a wheel that depends on the user's machine — which is why the load gate must run in a container with no geospatial libraries installed

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.

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

  • delvewheel and delocate project READMEs for the per-platform repair internals.