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.

Two cross-build lanes converging on one target-tagged wheel A single x86_64 CI runner driving cibuildwheel branches into two lanes that both target aarch64. The emulated-native lane registers QEMU binfmt, runs an aarch64 manylinux container whose own native aarch64-gcc compiles and links against the in-image sysroot, then runs auditwheel repair. The true-cross lane exports the aarch64-linux-gnu-gcc cross compiler with a CMake toolchain file, links vendored libproj and libgdal from a target sysroot, then runs auditwheel repair. Both lanes converge on a single manylinux_2_28_aarch64 tagged wheel that pip selects on the target architecture. Emulated-native lane — the container's own compiler builds under QEMU True-cross lane — a host compiler emits foreign code against a target sysroot x86_64 runner cibuildwheel Register QEMU binfmt_misc arm64 aarch64 container native aarch64-gcc auditwheel repair patch RPATH · bundle libs Cross compiler aarch64-linux-gnu-gcc Target sysroot vendored libproj+libgdal auditwheel repair patch RPATH · bundle libs Tagged target wheel manylinux_2_28_aarch64 pip selects on target arch

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. The archs and 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 imagesaarch64-linux-gnu-gcc lives in the manylinux2014_aarch64 / manylinux_2_28_aarch64 images. Pin images by digest, not :latest, because the bundled binutils/glibc define the wheel’s floor ABI.
  • Emulation: qemu-user-static registered via docker/setup-qemu-action@v3 for building Linux aarch64/ppc64le/s390x wheels on an x86_64 runner.
  • macOS: Xcode 15+ command-line tools providing xcrun, clang, and both arm64 and x86_64 slices. Set MACOSX_DEPLOYMENT_TARGET explicitly (11.0 for arm64).
  • Windows: the MSVC build tools with the ARM64 cross components, or an LLVM/clang-cl install; CMake 3.21+ for CMAKE_GENERATOR_PLATFORM=ARM64.
  • Build frontend: build (PEP 517) plus scikit-build-core 0.8+ or setuptools 69+, 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:

  1. Target triple mapping — explicit routing to aarch64-unknown-linux-gnu, arm64-apple-darwin, x86_64-apple-darwin, or aarch64-pc-windows-msvc.
  2. Sysroot provisioning — an isolated filesystem tree carrying the target libc, libstdc++, and kernel headers.
  3. Cross-package resolutionpkg-config and 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

  1. 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
    
  2. 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
    
  3. Provision or mount the target sysroot (true-cross path). Point pkg-config at 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
    
  4. Route the build frontend to the cross compiler. Export the toolchain in the job environment so scikit-build-core/setuptools cannot probe host gcc:

    export CC=aarch64-linux-gnu-gcc
    export CXX=aarch64-linux-gnu-g++
    export CMAKE_TOOLCHAIN_FILE=/opt/cross/aarch64-linux-gnu.cmake
    
  5. 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
    
  6. Repair the wheel to embed the vendored geospatial libraries. auditwheel rewrites RPATHs and bundles libproj/libgdal/libgeos_c into 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 aarch64 build 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 manylinux aarch64 wheel will not load on Alpine; if you ship musl you need a parallel musllinux_1_2_aarch64 leg 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 universal2 wheel (-arch arm64 -arch x86_64) from a single Xcode toolchain over a separate cross job; delocate fuses 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=hidden and export only PyInit_<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; enforcing Py_LIMITED_API there also shrinks the matrix you have to cross-build.
  • Reproducibility. Set SOURCE_DATE_EPOCH and pin the toolchain digest so identical source commits emit byte-identical .whl files, 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.

Further Reading

  • cibuildwheel cross/emulation docs (cibuildwheel.readthedocs.io/en/stable/faq/#emulation).
  • PEP 599 — manylinux2014 platform tag specification (peps.python.org/pep-0599).