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

QEMU emulation versus native cross-compilation for aarch64 wheels Two lanes both start from an x86_64 CI runner. The upper QEMU lane runs an emulated aarch64 container that compiles GDAL and the extension under binary translation, taking roughly forty minutes. The lower cross lane runs a native x86_64 aarch64-linux-gnu cross-toolchain against an aarch64 sysroot, compiling in roughly four minutes, then both lanes converge on auditwheel repair producing the same aarch64 wheel. x86_64 runner GitHub / GitLab CI QEMU emulated aarch64 binary translation ~40 min build native cross-toolchain aarch64-linux-gnu-gcc + sysroot ~4 min build auditwheel repair --plat manylinux_2_28 *-aarch64.whl

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.