Cross-Compiler Toolchain Setup for Geospatial Python Wheels
A production-grade cross-compiler toolchain is what lets you ship Python geospatial wheels for an architecture that differs from the CI runner’s native host — aarch64 wheels built on x86_64, Apple Silicon arm64 built on Intel macOS, or Windows ARM64 built on AMD64. This guide sits under the Geospatial C-Extension Fundamentals & ABI Architecture reference and isolates compiler routing, sysroot provisioning, and CI matrix configuration from the higher-level packaging concerns covered elsewhere in that section. It targets cibuildwheel 3.0+, manylinux2014/manylinux_2_28 images, GCC cross-toolchains (aarch64-linux-gnu-gcc), auditwheel 6.x, delocate 0.11+, delvewheel 1.x, and scikit-build-core 0.8+, for maintainers of pyproj, rasterio, shapely, fiona, and hand-rolled GDAL bindings.
Cross-compilation removes the need for an expensive native hardware matrix while keeping binary output deterministic. The discipline that makes it reliable is strict separation between the host build environment (where cibuildwheel, the build frontend, and CMake run) and the target execution environment (where the resulting .so/.pyd will dlopen at runtime). Every include path, library search directory, and compiler flag must be declared explicitly so the build never silently resolves a host-native header against a foreign target.
Prerequisites & Environment
Before wiring a matrix, pin every moving part. Cross-compilation fails non-deterministically when the toolchain, sysroot, and build frontend drift independently between runs.
- Build driver:
cibuildwheel >= 3.0. Thearchsand per-platform image options changed names across 2.x → 3.x, so pin exactly. - Linux toolchain: the cross GCC bundled inside the manylinux and manyARM Docker base images —
aarch64-linux-gnu-gcclives in themanylinux2014_aarch64/manylinux_2_28_aarch64images. Pin images by digest, not:latest, because the bundledbinutils/glibcdefine the wheel’s floor ABI. - Emulation:
qemu-user-staticregistered viadocker/setup-qemu-action@v3for building Linuxaarch64/ppc64le/s390xwheels on anx86_64runner. - macOS: Xcode 15+ command-line tools providing
xcrun,clang, and botharm64andx86_64slices. SetMACOSX_DEPLOYMENT_TARGETexplicitly (11.0forarm64). - Windows: the MSVC build tools with the ARM64 cross components, or an LLVM/
clang-clinstall; CMake 3.21+ forCMAKE_GENERATOR_PLATFORM=ARM64. - Build frontend:
build(PEP 517) plusscikit-build-core0.8+ orsetuptools69+, whichever your project uses.
Pin these in a lock file so the runner cannot resolve a newer toolchain mid-release. A pixi environment or conda lock captures the host-side build tools (cmake, ninja, pkg-config, patchelf) with a hash, which keeps the cross build reproducible across CI runs and developer machines.
The toolchain must resolve three orthogonal concerns, and none of them may fall back implicitly to the host:
- Target triple mapping — explicit routing to
aarch64-unknown-linux-gnu,arm64-apple-darwin,x86_64-apple-darwin, oraarch64-pc-windows-msvc. - Sysroot provisioning — an isolated filesystem tree carrying the target
libc,libstdc++, and kernel headers. - Cross-package resolution —
pkg-configand CMake toolchain files that point exclusively at the target’s PROJ/GDAL/GEOS artifacts.
Core Configuration
There are two viable strategies, and the choice drives every other setting. Emulated-native runs an aarch64 container under QEMU and uses the image’s own native compiler — simplest, slower at build time. True cross-compilation runs an x86_64 compiler that emits aarch64 code against a target sysroot — faster, but every dependency search root must be redirected by hand.
The emulated-native path is configured almost entirely in the GitHub Actions workflow, because the “cross” part collapses into “run a native build inside a foreign-arch container”:
jobs:
build-wheels:
name: Build geospatial wheels
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
# Pair each OS only with the arch names cibuildwheel accepts for it
# (Linux: x86_64/aarch64, macOS: x86_64/arm64, Windows: AMD64/ARM64).
include:
- { os: ubuntu-latest, cibw-arch: x86_64 }
- { os: ubuntu-latest, cibw-arch: aarch64 }
- { os: macos-14, cibw-arch: arm64 }
- { os: macos-13, cibw-arch: x86_64 }
- { os: windows-latest, cibw-arch: AMD64 }
- { os: windows-latest, cibw-arch: ARM64 }
steps:
- uses: actions/checkout@v4
- name: Set up QEMU
if: runner.os == 'Linux' && matrix.cibw-arch == 'aarch64'
uses: docker/setup-qemu-action@v3
with:
platforms: linux/arm64
- name: Build wheels
uses: pypa/cibuildwheel@v3.0
env:
CIBW_ARCHS: ${{ matrix.cibw-arch }}
CIBW_BUILD: "cp3{9,10,11,12,13}-*"
CIBW_BUILD_FRONTEND: "build"
with:
output-dir: dist
cibw-arch is enumerated explicitly rather than relying on auto. That prevents silent fallbacks and keeps matrix expansion deterministic — auto will happily try to build an architecture the runner cannot emulate and fail deep into the job. QEMU is registered only on the aarch64 Linux leg so the other legs do not inherit an emulator they never use.
The true-cross path needs the foreign compiler declared so the build frontend never probes the host PATH for gcc. Keep cross settings in a profile that only applies to the cross leg, never a global block that the x86_64, macOS, and Windows legs would inherit:
[tool.cibuildwheel]
build-frontend = "build"
# A dedicated cross profile, selected by an env var in the aarch64 job only.
[tool.cibuildwheel.linux]
manylinux-aarch64-image = "quay.io/pypa/manylinux_2_28_aarch64@sha256:<pinned-digest>"
# Patch RPATHs and bundle vendored geospatial libs for the target tag.
repair-wheel-command = "auditwheel repair --plat manylinux_2_28_aarch64 -w {dest_dir} {wheel}"
[tool.cibuildwheel.environment]
# Force cross-compiler resolution; never let setuptools/CMake fall back to host gcc.
CC = "aarch64-linux-gnu-gcc"
CXX = "aarch64-linux-gnu-g++"
CMAKE_TOOLCHAIN_FILE = "/opt/cross/aarch64-linux-gnu.cmake"
# Pin pkg-config to the target sysroot so PROJ/GDAL .pc files resolve there.
PKG_CONFIG_SYSROOT_DIR = "/usr/aarch64-linux-gnu"
PKG_CONFIG_LIBDIR = "/usr/aarch64-linux-gnu/lib/pkgconfig"
When the project drives its native build through CMake, the scikit-build-core backend reads CMAKE_TOOLCHAIN_FILE from this environment and applies it before any find_package call runs. The toolchain file is where you pin the find-root behaviour so find_package(PROJ) and find_package(GDAL) resolve exclusively against the cross-built artifacts:
# /opt/cross/aarch64-linux-gnu.cmake
set(CMAKE_SYSTEM_NAME Linux)
set(CMAKE_SYSTEM_PROCESSOR aarch64)
set(CMAKE_C_COMPILER aarch64-linux-gnu-gcc)
set(CMAKE_CXX_COMPILER aarch64-linux-gnu-g++)
set(CMAKE_SYSROOT /usr/aarch64-linux-gnu)
set(CMAKE_FIND_ROOT_PATH /usr/aarch64-linux-gnu /opt/vendor/aarch64)
# Find programs on the HOST, but headers and libraries ONLY in the target tree.
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM BOTH)
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY)
The CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY line is the single most important setting in the file: it is what stops CMake from linking the host x86_64 libproj.so into an aarch64 extension. Which PROJ/GDAL artifacts live under that root is decided by your PROJ and GDAL vendoring strategy.
Step-by-Step Implementation
-
Pin the base image and confirm its cross toolchain. Resolve the digest and verify the compiler is present inside the image:
docker pull quay.io/pypa/manylinux_2_28_aarch64 docker inspect --format '{{index .RepoDigests 0}}' \ quay.io/pypa/manylinux_2_28_aarch64 docker run --rm quay.io/pypa/manylinux_2_28_aarch64 \ gcc --version -
Register QEMU for emulated-native Linux builds. Install the binfmt handlers so the foreign-arch container can execute:
docker run --rm --privileged tonistiigi/binfmt --install arm64 cat /proc/sys/fs/binfmt_misc/qemu-aarch64 # expect: enabled -
Provision or mount the target sysroot (true-cross path). Point
pkg-configat it before any compile runs:export PKG_CONFIG_SYSROOT_DIR=/usr/aarch64-linux-gnu export PKG_CONFIG_LIBDIR=/usr/aarch64-linux-gnu/lib/pkgconfig aarch64-linux-gnu-gcc -print-sysroot pkg-config --cflags --libs proj -
Route the build frontend to the cross compiler. Export the toolchain in the job environment so
scikit-build-core/setuptoolscannot probe hostgcc:export CC=aarch64-linux-gnu-gcc export CXX=aarch64-linux-gnu-g++ export CMAKE_TOOLCHAIN_FILE=/opt/cross/aarch64-linux-gnu.cmake -
Build the wheels for the single target architecture. Constrain the matrix leg to one arch so nothing inherits the cross compiler:
CIBW_ARCHS=aarch64 CIBW_BUILD="cp312-manylinux_aarch64" \ python -m cibuildwheel --platform linux --output-dir dist -
Repair the wheel to embed the vendored geospatial libraries.
auditwheelrewrites RPATHs and bundleslibproj/libgdal/libgeos_cinto the wheel:auditwheel repair --plat manylinux_2_28_aarch64 \ -w wheelhouse/ dist/*-cp312-cp312-linux_aarch64.whl
Verification
Confirm the architecture and ABI of the produced artifact before trusting it. The headline check is that the machine type of the embedded .so matches the target, not the host:
# Inspect the repaired wheel's tag, embedded libs, and external deps.
auditwheel show wheelhouse/*-cp312-cp312-manylinux_2_28_aarch64.whl
# Unpack and confirm the extension is an AArch64 ELF, not x86-64.
python -m wheel unpack wheelhouse/*.whl -d /tmp/whl
file /tmp/whl/*/**/_geospatial_ext*.so
# Expect: ELF 64-bit LSB shared object, ARM aarch64, ... dynamically linked
cibuildwheel can list exactly which build identifiers a matrix leg will produce — run this in a dry step to catch a mis-scoped matrix before burning emulation time:
python -m cibuildwheel --print-build-identifiers --platform linux
# e.g. cp312-manylinux_aarch64 (and NOT any cp3XX-manylinux_x86_64)
Finally, prove the wheel actually imports under the target interpreter. Inside an emulated aarch64 container (or on a real ARM runner), install and import:
docker run --rm --platform linux/arm64 -v "$PWD/wheelhouse:/w" \
python:3.12-slim bash -c \
"pip install --force-reinstall /w/*.whl && python -c 'import pyproj; print(pyproj.proj_version_str)'"
# Expect the PROJ version string with no OSError / undefined-symbol traceback.
A clean auditwheel show plus a successful import on the target is the pass criterion; a wheel that builds but raises OSError on import has a sysroot or RPATH defect, not a compile defect.
Optimization & Edge Cases
- Emulation is the long pole. A QEMU-emulated
aarch64build of GDAL bindings can run 5–10× slower than native. Cache aggressively: key your build cache for C-extensions on the image digest plus the pinned PROJ/GDAL versions so emulated object files survive across runs. Where a native ARM runner is available, prefer it over QEMU outright. - musl vs glibc forks the build, not just the tag. A
manylinuxaarch64wheel will not load on Alpine; if you ship musl you need a parallelmusllinux_1_2_aarch64leg with its own cross toolchain. The tradeoffs and image choices are covered in manylinux2014 vs musllinux for spatial libs. - Keep the cross compiler out of the universal2 macOS path. On macOS, prefer building a
universal2wheel (-arch arm64 -arch x86_64) from a single Xcode toolchain over a separate cross job;delocatefuses both slices. Only split the macOS matrix when a dependency cannot produce a fat binary. - Symbol visibility still matters across the arch boundary. Compile extensions with
-fvisibility=hiddenand export onlyPyInit_<module>, so two cross-built geospatial packages cannot collide in one process. The full symbol contract is governed by C-API vs CPython ABI compatibility; enforcingPy_LIMITED_APIthere also shrinks the matrix you have to cross-build. - Reproducibility. Set
SOURCE_DATE_EPOCHand pin the toolchain digest so identical source commits emit byte-identical.whlfiles, which is what supply-chain attestation downstream depends on.
Troubleshooting
cibuildwheel: Building for aarch64 wheels requires emulation, but no emulation was set up
The aarch64 Linux leg ran without registering QEMU. Add the docker/setup-qemu-action@v3 step (gated to the aarch64 leg) before the cibuildwheel step, or move the build onto a native ARM runner. Confirm with cat /proc/sys/fs/binfmt_misc/qemu-aarch64 showing enabled.
/usr/bin/ld: skipping incompatible /usr/lib/x86_64-linux-gnu/libproj.so when searching for -lproj followed by /usr/bin/ld: cannot find -lproj
The linker found the host x86_64 PROJ and refused it, then had no target copy to fall back on. The sysroot redirect is missing: set CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY in the toolchain file and export PKG_CONFIG_LIBDIR/PKG_CONFIG_SYSROOT_DIR so the cross libproj.so under /usr/aarch64-linux-gnu is the only candidate.
Package proj was not found in the pkg-config search path. Perhaps you should add the directory containing 'proj.pc' to the PKG_CONFIG_PATH environment variable
pkg-config is still searching the host tree. For cross builds you must set PKG_CONFIG_LIBDIR (which replaces the default search path) rather than PKG_CONFIG_PATH (which appends to it), and set PKG_CONFIG_SYSROOT_DIR so the returned -I/-L paths are prefixed with the sysroot.
OSError: .../_geospatial_ext.cpython-312-aarch64-linux-gnu.so: cannot open shared object file: No such file or directory on import
The extension compiled for the right arch but its vendored libgdal.so/libproj.so was not bundled, so the loader cannot find the dependency at runtime. Re-run auditwheel repair (it embeds the libs and rewrites RPATH to $ORIGIN/../lib); if RPATH is still wrong, the linking model is the real fault — see shared library path resolution.
ImportError: dynamic module does not define module export function (PyInit__geospatial_ext) after a successful cross build
-fvisibility=hidden hid the init symbol without a matching PyMODINIT_FUNC export, or the wrong target’s libpython was linked. Confirm the init function is declared PyMODINIT_FUNC and that you linked the target image’s interpreter, never the host’s.
Related
- Geospatial C-Extension Fundamentals & ABI Architecture — the parent reference covering compile, link, repair, and import for native geospatial extensions.
- C-API vs CPython ABI Compatibility — how
Py_LIMITED_APIand symbol visibility shrink the architecture matrix you must cross-build. - Vendoring PROJ and GDAL vs System Libraries — what populates the target sysroot’s
find-rootand how static C++ runtimes interact with cross-linking. - Shared Library Path Resolution — RPATH,
$ORIGIN, and loader behaviour for the cross-built.soafterauditwheelrepair. - manylinux and manyARM Docker Base Images — the image lineage that ships the bundled cross GCC and defines each wheel’s floor ABI.
Further Reading
cibuildwheelcross/emulation docs (cibuildwheel.readthedocs.io/en/stable/faq/#emulation).- PEP 599 —
manylinux2014platform tag specification (peps.python.org/pep-0599).