Building universal2 GDAL wheels for Apple Silicon
This page answers one question: how do you produce a macOS universal2 wheel for a GDAL-linked extension that runs natively on both Apple Silicon arm64 and Intel x86_64 — when GDAL, PROJ, and GEOS themselves must also be fat binaries, and delocate has to fuse the right slices? It sits inside the Platform-Specific ABI Quirks section of the Geospatial C-Extension Fundamentals & ABI Architecture reference, and gives you the -arch flags, the lipo merge for dependencies, and the otool verification.
Context & Root Cause
A macOS universal2 binary is a “fat” Mach-O containing two complete slices — arm64 and x86_64 — so one file runs natively on either processor, and pip installs the same wheel on an M-series Mac and an Intel Mac. The wheel tag promises this, and macOS enforces it: loading a single-architecture dylib into a process running the other architecture raises incompatible architecture. The trap for geospatial extensions is transitive. Your extension is easy to build fat with -arch arm64 -arch x86_64, but it links GDAL, and GDAL links PROJ, GEOS, libtiff, and libsqlite3. Every library in that graph must also be universal2, or the fat extension fails to link the missing slice.
Homebrew and most prebuilt GDAL packages ship single-architecture, which is why the naive build succeeds for the host arch and fails for the other. The reliable path is to build (or lipo-merge) each native dependency as a fat binary, then let delocate fuse the two extension slices and bundle the fat dependencies. This is the macOS specialization of the repair contract from Platform-Specific ABI Quirks.
Solution / Fix
This targets macOS 12+, GDAL 3.8.x/PROJ 9.3.x built for both arches, delocate 0.11+, and cibuildwheel 3.0+.
1. Compile the extension for both architectures
export CFLAGS="-arch arm64 -arch x86_64 -mmacosx-version-min=12.0"
export LDFLAGS="-arch arm64 -arch x86_64"
export ARCHFLAGS="-arch arm64 -arch x86_64" # honoured by setuptools
python -m build --wheel
2. Make every native dependency fat
If your GDAL/PROJ came as two single-arch builds, merge each library with lipo:
lipo -create \
arm64/lib/libgdal.dylib x86_64/lib/libgdal.dylib \
-output fat/lib/libgdal.dylib
lipo -info fat/lib/libgdal.dylib
# expected: Architectures ... are: x86_64 arm64
Point the link step at fat/lib so the universal2 extension resolves both slices of GDAL.
3. Fuse and bundle with delocate
# delocate-fuse merges two thin wheels; delocate-wheel bundles fat deps
delocate-wheel --require-archs arm64,x86_64 -w repaired/ -v dist/*.whl
--require-archs arm64,x86_64 makes delocate fail loudly if any bundled library is missing a slice, converting a latent runtime crash into a build error. Drive the whole matrix from cibuildwheel with CIBW_ARCHS_MACOS=universal2.
Verification
# 1. The extension itself must be fat
lipo -info repaired/*.whl >/dev/null 2>&1; unzip -o repaired/*.whl -d /tmp/u >/dev/null
lipo -info /tmp/u/*.so
# expected: Architectures in the fat file: ... x86_64 arm64
# 2. Every bundled dylib must be fat too
for d in /tmp/u/.dylibs/*.dylib; do lipo -info "$d"; done
# expected: each reports both x86_64 and arm64
# 3. Native run on Apple Silicon AND under Rosetta
python -c "from osgeo import gdal; print(gdal.__version__)" # arm64
arch -x86_64 python -c "from osgeo import gdal; print(gdal.__version__)" # x86_64
# expected: both print 3.8.x
Two fat binaries and two clean imports (native and under arch -x86_64) prove the wheel is genuinely universal. A missing slice in step 2 is exactly what --require-archs should have caught.
The Dependency Graph Has to Be Fat All the Way Down
The failure that wastes the most time on this task is not the extension — it is a library four levels below it. Your code links GDAL; GDAL links PROJ, GEOS, libtiff, libgeotiff, libwebp, libsqlite3 and libcurl; PROJ links SQLite and libtiff again. A single thin .dylib anywhere in that graph makes the fat link fail, and the error names only the immediate dependency, not the transitive one that is actually missing a slice.
Two practices make that tractable. The first is to audit the whole graph rather than the top of it: walk every .dylib in the prefix you are linking against and assert both slices are present, before the extension build starts. The second is delocate’s --require-archs, which turns the latent problem into a build failure at repair time rather than an incompatible architecture crash on a user’s Intel Mac months later.
# Audit the entire prefix, not just the libraries you name explicitly
fail=0
while IFS= read -r lib; do
archs=$(lipo -archs "$lib" 2>/dev/null || echo "?")
case "$archs" in
*arm64*x86_64*|*x86_64*arm64*) ;;
*) echo "THIN: $lib ($archs)"; fail=1 ;;
esac
done < <(find fat/lib -name '*.dylib')
[ "$fail" = 0 ] && echo "every dylib is universal"
There is a subtlety in how the two slices are produced that matters for correctness rather than convenience. Building with -arch arm64 -arch x86_64 in one invocation compiles each source file twice and lets the compiler emit a fat object directly. Building twice and merging with lipo produces a file that is structurally identical but was configured twice — and if the configure step probed the host and baked host-specific answers into a header, the two slices can disagree about, say, endianness assumptions or the size of a type. Prefer the single fat build where the project supports it, and where you must merge, merge the libraries rather than the configuration.
Inside a Fat Mach-O
A universal2 file is not a binary with two modes; it is two complete binaries behind a small index. Knowing the layout makes lipo output and incompatible architecture errors easy to read.
The per-slice signature is the detail that explains the ordering rule stated earlier: because the signature lives inside each Mach-O rather than alongside the fat file, merging after signing produces a file whose slices each claim a signature computed over different bytes than they now contain.
Choosing Between universal2 and Two Thin Wheels
universal2 is not the only way to serve both Mac architectures, and for many spatial projects it is not the best one. The alternative is publishing separate macosx_11_0_arm64 and macosx_10_13_x86_64 wheels, which pip selects between automatically.
The thin-wheel path wins on almost every practical axis. Each user downloads roughly half the bytes, because they receive only the slice they can run — and for a GDAL wheel that is a difference of tens of megabytes. The two builds run in parallel in the matrix rather than doubling one job’s duration. Dropping Intel support later becomes a matrix change rather than a rebuild of every dependency. And the audit above shrinks to an ordinary single-architecture check.
universal2 wins in exactly one situation, but it is a real one: when the wheel is going to be embedded inside an application bundle that must itself be universal, or into a redistributable environment that will be copied between machines of both architectures. In those cases a thin wheel forces the packager to build two environments, and a fat wheel is genuinely simpler.
The middle path, which several large projects use, is to publish thin wheels as the default and produce a universal2 artifact only on demand for downstream packagers. delocate-fuse exists precisely for this: it merges two already-repaired thin wheels into one fat wheel, so the fat variant is a post-processing step rather than a parallel build path with its own dependency graph to keep universal.
Frequently Asked Questions
What deployment target should a universal2 wheel declare?
The oldest macOS you intend to support, and remember that the two slices have different floors in practice: Apple Silicon did not exist before macOS 11, so an arm64 slice cannot meaningfully target anything older, while the x86_64 slice can go back further. universal2 wheels conventionally declare 11.0 for that reason, which also means a universal2 wheel cannot serve Intel users on macOS 10.15 — a case where thin wheels are strictly more capable.
Why does pip install my universal2 wheel but the import still fails?
Because installation checks the wheel’s tag, not its contents. A wheel tagged universal2 whose bundled libwebp is Intel-only installs perfectly and fails at import on Apple Silicon with incompatible architecture. This is exactly the gap --require-archs closes, and it is why the acceptance test must run under both arch -arm64 and arch -x86_64.
Can I test the Intel slice on an Apple Silicon machine?
Yes, through Rosetta 2: arch -x86_64 python -c "..." runs the Intel slice provided an Intel build of Python is available. That is a genuine test of the slice’s correctness, though not of its performance, and it is enough to catch the missing-slice class of failure. Running the reverse — an arm64 slice on an Intel Mac — is not possible, so the arm64 path needs a real Apple Silicon runner.
Does code signing behave differently for fat binaries?
The signature covers both slices, and any post-signing modification to either one invalidates it. That makes ordering stricter than in a thin build: merge with lipo first, rewrite install names second, sign last. A lipo merge performed after signing produces a binary that macOS refuses to load with a signature error, which reads as a permissions problem and is actually an ordering one.
Pitfalls & Alternatives
Building fat against thin dependencies. The extension compiles with both -arch flags but the linker only finds a single-arch GDAL, so one slice silently links nothing and you get incompatible architecture at import. Merge dependencies with lipo first and pass --require-archs.
Forgetting -mmacosx-version-min. Mismatched deployment targets between slices trip notarization and produce building for macOS ... but linking ... built for newer warnings that become hard errors. Set one floor for both arches.
Shipping universal2 when nobody needs Intel. Fat wheels double build time and size. If telemetry shows an all-Apple-Silicon user base, ship a thin arm64 wheel and drop the x86_64 slice; the trade-off is the same matrix-pruning judgement as cibuildwheel vs manual Docker matrix for GDAL wheels.
Related
- Platform-Specific ABI Quirks — the parent guide comparing macOS install names to Linux RPATH and Windows DLL search.
- Fixing “DLL load failed” for GDAL on Windows — the sibling Windows-side loader fix.
- Cross-Compiler Toolchain Setup — the general cross-architecture build model that
universal2is one instance of.