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.

Fusing arm64 and x86_64 slices into one universal2 wheel Two build passes produce an arm64 extension linked against arm64 GDAL and an x86_64 extension linked against x86_64 GDAL. A lipo merge combines the native libraries into fat dylibs, and delocate fuses the two extension slices and bundles the fat dependencies into a single universal2 wheel that runs natively on both architectures. arm64 build -arch arm64 · arm64 GDAL x86_64 build -arch x86_64 · x86_64 GDAL lipo + delocate fuse slices · bundle universal2 wheel runs on both arches

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.