Using conda-forge compilers for wheel builds

This page answers one question: you want a pinned, reproducible toolchain for compiling GDAL and your extension, and conda-forge ships compilers as ordinary packages — so how do you use them to build a wheel that does not depend on the conda prefix it was built in? It sits inside the Environment Isolation with Pixi and Conda section of the Modern Python Build Tooling & Wheel Configuration reference, and gives you the environment, the activation, and the repair step that severs the dependency.

A conda-forge build environment producing a wheel that does not need it A pinned conda environment supplies the compiler, the sysroot, CMake and the geospatial libraries with their headers. The build compiles the extension against that prefix. The repair step then copies the required libraries out of the prefix into the wheel and rewrites the load paths, so the finished artifact runs on a machine with no conda at all. Without the repair step the wheel keeps absolute references into the prefix and works only where that prefix exists. conda environment compiler · sysroot · cmake gdal · proj · headers build compiles against the prefix repair copies libraries in rewrites load paths without the repair the wheel carries absolute references into the prefix and installs nowhere else with it, the artifact is ordinary and the conda environment was only a build tool

Context & Root Cause

conda-forge publishes the C and C++ compilers, the target sysroot and the geospatial stack as packages, which makes a build environment expressible in a lock file: the same compiler, the same glibc floor and the same GDAL on a laptop and on a runner. That is a genuinely better starting point than installing a distribution’s build tools and hoping the versions match.

The catch is that the resulting wheel must not depend on any of it. Compiling against a conda prefix records absolute paths into that prefix, and a wheel carrying those references installs and then fails on any machine without the same environment. The build is a conda concern; the artifact must be an ordinary manylinux wheel. That separation is exactly what the repair step provides, and forgetting it is the single failure mode this approach has.

Solution / Fix

This targets conda-forge compilers with a manylinux-compatible sysroot, GDAL 3.8.x, auditwheel 6.x and scikit-build-core 0.9+.

1. Declare a build environment with the toolchain in it

# pixi.toml
[project]
channels = ["conda-forge"]
platforms = ["linux-64", "osx-arm64"]

[feature.build.dependencies]
c-compiler = "*"
cxx-compiler = "*"
"sysroot_linux-64" = "2.28.*"     # sets the glibc floor for the wheel tag
cmake = ">=3.28"
ninja = "*"
gdal = "3.8.*"
proj = "9.3.*"
geos = "3.12.*"
scikit-build-core = ">=0.9"
auditwheel = ">=6"

[environments]
build = ["build"]

The sysroot pin is the load-bearing line: it is what fixes the glibc version the compiler targets, and therefore the platform tag the finished wheel can claim.

2. Build inside the environment

pixi run -e build python -m build --wheel --outdir dist/raw

Activation sets CC, CXX, CFLAGS and the sysroot flags, so the build finds the pinned toolchain without any of it being hard-coded in your project.

3. Repair the wheel out of the prefix

LD_LIBRARY_PATH="$(pixi run -e build printenv CONDA_PREFIX)/lib" \
  pixi run -e build auditwheel repair --plat manylinux_2_28_x86_64 \
  -w dist/ dist/raw/*.whl

LD_LIBRARY_PATH here tells the repair tool where to find the libraries it must copy in; without it the tool reports them as unresolvable external references.

4. Prove the artifact is independent

docker run --rm -v "$PWD/dist:/d" python:3.12-slim bash -c \
  "pip install -q /d/*.whl && python -c 'from osgeo import gdal; print(gdal.__version__)'"

Verification

# 1. No path into the conda prefix survives in the wheel
unzip -o dist/*.whl -d /tmp/w >/dev/null
grep -rl "$CONDA_PREFIX" /tmp/w 2>/dev/null || echo "no prefix references"
readelf -d /tmp/w/**/*.so | grep -E 'RPATH|RUNPATH'
# expected: only $ORIGIN-relative entries
# 2. The platform tag matches the sysroot you pinned
auditwheel show dist/*.whl | grep -i 'platform tag'
# expected: manylinux_2_28_x86_64
# 3. It imports where no conda exists
docker run --rm -v "$PWD/dist:/d" python:3.12-slim bash -c \
  "pip install -q /d/*.whl && python -c 'import geo_core; print(geo_core.VENDORED)'"

The first check is the one to automate. A single absolute path into a build prefix is enough to make a wheel unusable elsewhere, and it is invisible in every test run on the build machine.

What the conda Toolchain Actually Pins

The value of this approach is proportional to how much of the build it makes reproducible, so it is worth being precise about what the environment does and does not fix.

Which build inputs a conda environment pins and which it does not Pinned by the environment: the compiler and its version, the sysroot and therefore the glibc floor, CMake and the generator, and the geospatial libraries with their headers. Not pinned: the container image and kernel, environment variables inherited from the CI job, the repair tool's behaviour where it is installed outside the environment, and anything the build downloads at run time. A note observes that the unpinned list is what a digest-pinned image covers. pinned by the lock file the C and C++ compilers the sysroot, and the glibc floor CMake, Ninja, pkg-config GDAL, PROJ, GEOS and headers the build backend itself identical on a laptop and a runner not pinned by it the container image and kernel variables inherited from CI tools installed outside the env anything downloaded at build time the wall clock and the build path covered by a digest-pinned image

The two columns are complementary rather than alternatives. Pinning the environment without pinning the image leaves the kernel, the container’s own libc and any tool installed by the image floating; pinning the image without the environment leaves the compiler and libraries to whatever the image happens to ship. Doing both is what makes a build reproducible in the sense described in reproducible builds and supply-chain attestation.

One inclusion in the left column deserves emphasis: putting auditwheel inside the environment rather than installing it separately means the repair behaviour is pinned too. A repair tool that updates independently can change which libraries it bundles or how it computes a tag, and that is exactly the sort of unannounced change that makes a release differ from its predecessor for no visible reason.

When This Beats a manylinux Image, and When It Does Not

Both approaches produce valid wheels, and the choice depends on what the project’s constraints actually are.

A conda build environment compared with a stock manylinux image The conda environment gives a lock file covering compilers and libraries, works identically on a developer laptop, and provides prebuilt GDAL so nothing has to be compiled from source; it needs the repair step to sever the prefix dependency and its sysroot pin must match the tag you claim. The manylinux image gives the policy tooling and the exact toolchain the tag assumes, and requires building or installing the geospatial stack inside it. conda build environment stock manylinux image GDAL comes from a prebuilt package you build or install it reproducible on a laptop yes, from the lock yes, by running the container glibc floor set by the sysroot package pin the image, by construction policy tooling present install auditwheel yourself shipped in the image main risk a wheel that keeps prefix paths a long native build every run the hybrid — a conda environment inside a pinned manylinux image — takes the useful half of each

The hybrid in the last line is what most mature spatial projects converge on. The image fixes the platform and carries the policy tooling; the conda environment inside it fixes the compiler and supplies a prebuilt GDAL so nothing spends twelve minutes compiling. The repair step then runs with the image’s auditwheel, which is the version the policy was written for.

Where the pure-conda approach shines on its own is local development and macOS, where there is no manylinux equivalent and a lock-file-defined toolchain is the most reproducible option available.

Pitfalls & Alternatives

Skipping the repair because “it works”. It works on the build machine, where the prefix exists. Repair is not an optimisation here; it is what converts an environment-specific build into a distributable artifact.

Mismatching the sysroot and the claimed tag. A sysroot_linux-64=2.17 environment producing a wheel labelled manylinux_2_28 claims less compatibility than it has; the reverse claims more than it has and fails on older systems. Let auditwheel compute the tag and check that it matches what you expected.

Installing the wheel into the build environment to test it. That environment has the libraries, so the test passes regardless of what the wheel contains. The clean container is the only honest check, as smoke-testing GDAL wheels in a clean container sets out.

Letting the build environment double as the test environment. Separate them in the manifest, as environment isolation with pixi and conda recommends; a test environment containing GDAL cannot prove the wheel carries its own.

Frequently Asked Questions

Does the wheel end up depending on conda in any way?

Not after repair. The bundled libraries were copied out of the prefix and their load paths rewritten to $ORIGIN, so the artifact is an ordinary wheel whose provenance happens to have been a conda environment. The verification step exists to make that a checked fact rather than an assumption.

Which sysroot version should I pin?

The one matching the platform tag you intend to publish — 2.28 for manylinux_2_28, 2.17 for manylinux2014. Pinning a newer sysroot than the tag produces a wheel that claims compatibility it does not have, which is the failure the tag computation is meant to catch.

Can I use this for macOS wheels?

Yes, and it is arguably more valuable there because there is no manylinux equivalent to standardise the toolchain. Pin the compilers and the deployment target in the environment, and use delocate in place of auditwheel.

Does it work with cibuildwheel?

It composes: run the conda environment setup in before-all and let the tool handle identifiers, repair and testing. The one thing to watch is that the environment’s variables reach the build backend, which under build isolation means listing them in the environment table rather than relying on activation.

Is the extra layer worth it if I already have a working manylinux build?

If the native stack is already prebuilt and cached, probably not — the marginal reproducibility gain is small. If your image builds GDAL from source on every run, replacing that with a pinned conda package removes the largest single cost in the pipeline, which is a different proposition entirely.

How do I keep the environment and the image from drifting apart?

Pin both by content — the lock file for the environment, the digest for the image — and bump them in separate, reviewed changes. Bumping both at once makes any resulting difference in the artifact impossible to attribute to one or the other.

Does the built wheel need the same conda environment to be tested?

Emphatically not — testing it there proves nothing, because the environment supplies the libraries the wheel is supposed to carry.