Building aarch64 GDAL wheels without QEMU
This page answers one question: how do you produce manylinux aarch64 wheels for a GDAL-linked extension on an x86_64 CI runner without paying the 10–40× slowdown of QEMU emulation? It sits inside the Cross-Compiler Toolchain Setup section of the Geospatial C-Extension Fundamentals & ABI Architecture reference, and gives you the cross-toolchain routing, the cibuildwheel configuration, and the verification commands that prove the resulting wheel is genuinely aarch64 and not a silently-native x86_64 artifact.
Context & Root Cause
When a maintainer adds aarch64 to a wheel matrix, the path of least resistance is docker/setup-qemu-action, which registers a binfmt_misc handler so an aarch64 container runs transparently on x86_64. It works, but every instruction executes under user-mode binary translation. Compiling GDAL, PROJ, and GEOS from source under that translation is CPU-bound work amplified by the emulator, which routinely turns a four-minute native build into a thirty-to-forty-minute one and occasionally hits the runner’s job timeout entirely.
Cross-compilation removes the emulator: the compiler runs as a native x86_64 binary but emits aarch64 machine code, linking against an aarch64 sysroot that supplies the target’s headers and libraries. The cost moves from wall-clock time to setup complexity, because the build must be told — at every layer, from the compiler triple to pkg-config to CMake’s find_package — to stop looking at the host tree and resolve everything from the sysroot instead. Getting that routing half-right is the failure mode: the extension links against host x86_64 GDAL, produces a wheel that looks built, and then fails to import on real hardware. The compiler-routing fundamentals live in Cross-Compiler Toolchain Setup; this page is the GDAL-specific recipe.
Solution / Fix
This targets cibuildwheel 3.0+, the manylinux_2_28 Docker base image, GCC 13 cross-toolchains, GDAL 3.8.x / PROJ 9.3.x, and auditwheel 6.x.
1. Provision the cross-toolchain and sysroot
The manylinux_2_28 image already bundles the aarch64 cross-GCC. Confirm the triple and point the build at it explicitly rather than relying on autodetection:
# Inside the manylinux_2_28_x86_64 image
export CROSS=aarch64-linux-gnu
export CC="${CROSS}-gcc"
export CXX="${CROSS}-g++"
export AR="${CROSS}-ar"
export SYSROOT=/opt/sysroots/aarch64
# pkg-config must REPLACE its search path, never append the host's
export PKG_CONFIG_LIBDIR="${SYSROOT}/usr/lib/pkgconfig:${SYSROOT}/usr/share/pkgconfig"
export PKG_CONFIG_SYSROOT_DIR="${SYSROOT}"
Setting PKG_CONFIG_LIBDIR (which replaces the path) instead of PKG_CONFIG_PATH (which appends) is the single most important line: append leaves the host libgdal.pc reachable, and the linker will happily pull the x86_64 archive into an aarch64 link.
2. Route CMake through a toolchain file
Geospatial extensions built with the scikit-build-core backend need a CMAKE_TOOLCHAIN_FILE so find_package(GDAL) searches only the sysroot. The dedicated walkthrough is CMake toolchain file for cross-compiling PROJ; the minimum for GDAL is:
# aarch64-toolchain.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 $ENV{SYSROOT})
# Search programs on the host, but libraries and headers ONLY in the sysroot
set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
3. Drive it from cibuildwheel
Set the architecture to aarch64 and force the cross environment inside the build container. Because no emulator is involved, do not register QEMU:
# pyproject.toml
[tool.cibuildwheel]
build = "cp39-*" # one abi3 wheel per platform
manylinux-aarch64-image = "manylinux_2_28"
[tool.cibuildwheel.linux]
archs = ["aarch64"]
environment = { CC = "aarch64-linux-gnu-gcc", CXX = "aarch64-linux-gnu-g++", CMAKE_TOOLCHAIN_FILE = "/project/aarch64-toolchain.cmake", PKG_CONFIG_SYSROOT_DIR = "/opt/sysroots/aarch64" }
# Cross-built wheels cannot run their own test suite on x86_64 — skip in-place tests
test-skip = "*-manylinux_aarch64"
The test-skip line matters: cibuildwheel normally imports the freshly built wheel to test it, but an aarch64 binary cannot execute on the x86_64 runner. Validate on real hardware in a downstream job instead (see Verification).
Verification
The danger with cross-builds is a wheel that is tagged aarch64 but contains x86_64 code, or vice versa. Prove the architecture three ways.
# 1. The compiled extension must be an ARM aarch64 ELF, not x86-64
unzip -o wheelhouse/*aarch64.whl -d /tmp/w >/dev/null
file /tmp/w/*.so
# expected: ELF 64-bit LSB shared object, ARM aarch64, ...
# 2. Every bundled native lib must also be aarch64
readelf -h /tmp/w/*.libs/libgdal*.so | grep Machine
# expected: Machine: AArch64
# 3. Real acceptance test — run on an actual arm64 host (or one QEMU job, just for the test)
docker run --rm --platform linux/arm64 -v "$PWD/wheelhouse:/w" python:3.12-slim \
bash -c "pip install /w/*aarch64.whl && python -c 'from osgeo import gdal; print(gdal.__version__)'"
# expected: 3.8.x
A passing build shows ARM aarch64 for both the extension and its bundled libgdal, and a clean import on real arm64. If file reports x86-64, the cross environment did not reach the compiler and cibuildwheel fell back to the host toolchain.
Pitfalls & Alternatives
Registering QEMU “just for safety.” If binfmt_misc is active, an accidental host build step still runs, and worse, the emulated interpreter can mask a cross-routing bug by importing the wheel successfully under emulation. Keep the cross job emulator-free so failures surface immediately.
Appending to PKG_CONFIG_PATH. This is the classic silent corruption: the host libgdal.pc resolves first, the linker mixes architectures, and you get ld: error: /usr/lib64/libgdal.so: incompatible target — or, worse, no error and a broken wheel. Always use PKG_CONFIG_LIBDIR for cross builds.
Forgetting the sysroot for PROJ’s data. Cross-compilation only handles code, not the proj.db datum database. The wheel still needs that data file bundled the same way a native build does, covered in bundling proj.db datum grids in a wheel. If you would rather trade build time for simplicity, the emulated matrix in cibuildwheel vs manual Docker matrix for GDAL wheels is the fallback when cross-routing is not worth the maintenance.
Where the Time Actually Goes
The case for cross-compiling rests on a single number, and it is worth seeing it broken down rather than taken on faith. A GDAL-linked extension build has four phases with very different sensitivities to emulation. Downloading sources and unpacking is I/O and barely changes. Configuring — cmake, configure, pkg-config probing — runs hundreds of tiny compile-and-run tests and suffers badly, because each one pays emulator startup. Compiling is pure CPU and takes the full multiplier. Repairing the wheel is I/O plus a little parsing and, again, barely moves.
Two conclusions follow. First, if your extension links a prebuilt GDAL rather than compiling one, emulation is far less painful and cross-compilation may not be worth the setup — the compile phase shrinks to your own handful of source files. Second, the configure phase is the hidden cost that surprises people: a build that spends eleven minutes running try_compile probes has not compiled a line of your code yet, and no amount of ccache helps because those probes are not cached.
Two Machines, One Build
The mental model that makes cross-compilation routine is to keep two machines in mind at all times: the one running the tools, and the one the output will run on. Every input to the build belongs to exactly one of them, and nearly every cross-build bug is an input that ended up on the wrong side.
Every environment variable in the recipe above exists to enforce that split, which is why setting one of them to append rather than replace is so damaging: appending keeps the host column reachable, and the host column always has answers.
Keeping the Cross Build Honest in CI
The dangerous property of a cross-build is that its failure mode is a successful build producing the wrong artifact. A native build that cannot find GDAL fails loudly; a cross-build that cannot find the target GDAL quietly finds the host’s and links it. The defence is a small set of assertions that run inside the build container, before the wheel is repaired, and fail the job rather than the user.
# Run inside the build container, immediately after the extension links
set -euo pipefail
ext=$(find build -name '_geospatial_ext*.so' | head -1)
file "$ext" | grep -q 'ARM aarch64' || { echo "FAIL: host arch leaked into the extension"; exit 1; }
readelf -d "$ext" | grep -q 'libgdal' || { echo "FAIL: no GDAL dependency recorded"; exit 1; }
"${CROSS}-readelf" -h "$SYSROOT/usr/lib/libgdal.so" | grep -q AArch64 \
|| { echo "FAIL: sysroot GDAL is not aarch64"; exit 1; }
echo "cross-build assertions passed"
Three assertions, three distinct leaks. The first catches the compiler falling back to the host toolchain, which happens whenever an environment variable does not survive into the build backend’s subprocess — a common outcome when a build system re-execs with a sanitised environment. The second catches a build that compiled but never linked GDAL, which produces a wheel that imports and then fails on first use. The third catches the sysroot itself being wrong, which is the failure that wastes the most time because everything downstream looks correct.
It is also worth asserting the absence of the emulator. If a previous job in the same workflow registered binfmt_misc, it stays registered for the runner’s lifetime, and an aarch64 binary will then execute silently on the host. That is convenient right up until it masks a routing bug: a test that passes under emulation tells you nothing about whether the cross-routing worked. Checking [ ! -e /proc/sys/fs/binfmt_misc/qemu-aarch64 ] at the start of the cross job makes the isolation explicit.
Finally, keep one emulated job in the pipeline — not for building, but for testing. A cross-built wheel cannot execute on the build runner, so something has to import it. A single QEMU-based import test costs a minute or two and closes the loop that test-skip opened; without it, the first machine to run your aarch64 wheel is a user’s.
Frequently Asked Questions
Can I cross-compile macOS arm64 wheels the same way?
You do not need to. Apple’s toolchain is a true cross-compiler by default: an Intel or Apple Silicon machine can emit either architecture with -arch, using the same SDK, so building arm64 on x86_64 is an ordinary flag rather than a sysroot exercise. The complications on macOS are elsewhere — deployment targets, fat binaries and code signing — and are covered in platform-specific ABI quirks.
What if my sysroot does not contain GDAL at all?
Then you have to build GDAL for the target first, cross-compiled the same way, and install it into the sysroot before your extension configures. That is the point where many teams switch to a prebuilt approach: publish a cross-built GDAL as a container layer or a tarball, cache it aggressively, and let the wheel build consume it. The one thing not to do is install the host distribution’s libgdal-dev and hope, since those headers describe an x86_64 build.
Does cross-compiling change the resulting wheel in any way?
It should not. A correctly cross-built wheel is byte-comparable in structure to a natively built one: same tag, same bundled libraries, same RUNPATH. If auditwheel show reports a different platform tag between the cross and native builds of the same source, the sysroot’s glibc differs from the native image’s, and one of the two is not the manylinux baseline you intended.
Is zig cc a realistic alternative to a GCC cross-toolchain?
For pure C projects with modest dependencies, yes — it bundles its own sysroots and removes most of the provisioning work. For a GDAL stack it is harder, because the C++ surface is large and GDAL’s build assumes GNU toolchain behaviours in several places. It is worth trying on a branch before committing to it, and worth keeping the GCC path working until the cross build is reproducible end to end.
Related
- Cross-Compiler Toolchain Setup — the parent guide on compiler triples, sysroots, and CI matrix routing this recipe specializes.
- CMake toolchain file for cross-compiling PROJ — the full
CMAKE_TOOLCHAIN_FILEthat scopesfind_packageinto the sysroot. - Shared library path resolution — how the cross-built
.soresolves its bundledlibgdalvia$ORIGINat import time.