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 cluster 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.
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.