Configuring pixi environments for wheel building
This page answers one question: how do you configure a pixi environment so that GDAL, PROJ, and PyProj wheels build deterministically across Linux, macOS, and Windows CI runners instead of failing with find_package misses, libproj.so import errors, or pip pulling incompatible prebuilt binaries? It sits inside the Environment Isolation with Pixi and Conda cluster of the Modern Python Build Tooling & Wheel Configuration reference, and gives you a copy-paste pixi.toml, the CMake routing it requires, and the exact commands to verify the result.
Context & Root Cause
Geospatial wheels break in CI for three deterministic reasons, all rooted in where the build resolves its native dependencies. First, environment drift: each runner ships a different libgdal, so an unpinned conda channel or system package yields a different ABI on every machine. Second, toolchain misrouting: the C/C++ compiler that builds the extension is not the one the C-libraries were compiled with, so symbols mismatch at link time. Third, implicit host resolution: CMake’s find_package(GDAL) silently binds to /usr/lib instead of the isolated prefix, baking a host path into the extension’s RPATH.
A pixi environment closes all three gaps because its solver pins every native library to a cryptographic pixi.lock, installs platform-matched compilers from conda-forge into the same prefix, and exposes that prefix through activation variables. The job of this page’s configuration is to point the build at that prefix and nowhere else. Get the routing wrong and the wheel still compiles — it just fails to import on a clean machine, which is the worst place to discover it.
Solution / Fix
The following targets pixi >= 0.30, scikit-build-core >= 0.10, cmake >= 3.28, and GDAL 3.8.x / PROJ 9.3.x. Pin the C-libraries to a single minor version so the compiled extension and the runtime library share one ABI.
1. Define an isolated pixi.toml
This manifest isolates the build environment, pins the geospatial C-libs for ABI stability, routes the build through Pixi’s task runner, and disables pip’s binary fallback so dependencies compile against the pinned libraries rather than downloading mismatched wheels.
[project]
name = "geospatial-wheel-builder"
version = "0.1.0"
description = "Isolated build environment for GDAL/PROJ/PyProj spatial wheels"
channels = ["conda-forge"]
platforms = ["linux-64", "osx-arm64", "win-64"]
channel-priority = "strict"
[dependencies]
python = ">=3.10,<3.13"
build = ">=1.2.0"
scikit-build-core = ">=0.10.0"
cmake = ">=3.28"
ninja = ">=1.11"
# Geospatial C-libraries (pinned for ABI stability)
gdal = ">=3.8,<3.9"
proj = ">=9.3,<9.4"
libgdal = ">=3.8,<3.9"
# Platform-specific compilers (conda-forge naming convention)
[target.linux-64.dependencies]
gcc_linux-64 = ">=13.0"
[target.osx-arm64.dependencies]
clang_osx-arm64 = ">=16.0"
[target.win-64.dependencies]
vs2022_win-64 = ">=19.38"
[pypi-dependencies]
pyproj = ">=3.6.0"
rasterio = ">=1.3.0"
[activation.env]
GDAL_DATA = "$PIXI_ENV_PREFIX/share/gdal"
PROJ_LIB = "$PIXI_ENV_PREFIX/share/proj"
PROJ_NETWORK = "OFF"
CMAKE_GENERATOR = "Ninja"
# Force source compilation during the wheel build
PIP_NO_BINARY = ":all:"
[tasks]
build-wheel = "python -m build --wheel --no-isolation"
validate = "python -c \"import pyproj, rasterio; print('Import validation passed')\""
clean = "rm -rf dist/ build/ *.egg-info"
Why each decision matters:
--no-isolationstopsbuildfrom spawning its own throwaway venv, so the Pixi-resolved toolchain — not a fresh PyPI download — is what compiles the extension.PIP_NO_BINARY = ":all:"blocks pip from substituting a precompiled wheel for a pinned C-library during dependency resolution, which is the most common source of a silent ABI swap.- The
[target.<platform>.dependencies]blocks pull conda-forge’s platform-matched compilers; Pixi selects the right one from the runner architecture automatically.
2. Pin CMake to the isolated prefix
Geospatial packages use the scikit-build-core backend to translate pyproject.toml into CMake invocations. Pixi injects the compilers onto $PATH, but find_package() will still resolve to host-installed libraries unless you scope the search roots explicitly. Drop a CMakePresets.json at the repository root:
{
"version": 3,
"configurePresets": [
{
"name": "pixi-geospatial",
"generator": "Ninja",
"cacheVariables": {
"GDAL_ROOT": "$env{PIXI_ENV_PREFIX}",
"PROJ_ROOT": "$env{PIXI_ENV_PREFIX}",
"CMAKE_PREFIX_PATH": "$env{PIXI_ENV_PREFIX}",
"CMAKE_INSTALL_RPATH_USE_LINK_PATH": "ON",
"CMAKE_BUILD_TYPE": "Release"
}
}
]
}
CMAKE_PREFIX_PATH and the *_ROOT hints force find_package(GDAL) and find_package(PROJ) into the Pixi prefix. CMAKE_INSTALL_RPATH_USE_LINK_PATH=ON adds that prefix’s link directories to the build-tree RPATH so the extension can load libgdal.so / libproj.so during local testing. Those are absolute paths, so a distributable wheel still needs a repair pass — see step 4. The same find_package routing problem is covered in depth in fixing CMake find_package for PROJ.
3. Resolve and build
pixi install # materialize the locked environment
pixi run clean # drop stale CMake cache and .pyc artifacts
pixi run build-wheel # python -m build --wheel --no-isolation
Pixi activates the environment (exporting PIXI_ENV_PREFIX, GDAL_DATA, PROJ_LIB) before invoking python -m build, and scikit-build-core picks up CMakePresets.json during the configure phase.
4. Repair RPATHs for distribution
The build-tree wheel carries absolute prefix paths and will only import where Pixi is installed. Rewrite them to relocatable $ORIGIN-based paths with the same repair tools used for the manylinux_2_28 Docker base images:
# Linux — manylinux_2_28 is required for GDAL 3.8+
LD_LIBRARY_PATH="$PIXI_ENV_PREFIX/lib" \
auditwheel repair --wheel-dir dist/ dist/*.whl
# macOS
delocate-wheel -w dist/ -v dist/*.whl
Verification
Run these three checks before publishing any artifact.
# 1. Confirm the extension links libgdal/libproj via $ORIGIN, not an absolute prefix
readelf -d dist/*.so | grep -E 'RPATH|RUNPATH'
# expected: 0x... (RUNPATH) Library runpath: [$ORIGIN/../geospatial_wheel_builder.libs]
# 2. Confirm a clean import without LD_LIBRARY_PATH overrides
pixi run validate
# expected: Import validation passed
# 3. Confirm the external library policy and ABI tag of the repaired wheel
auditwheel show dist/*.whl
# expected: "manylinux_2_28_x86_64" and no libraries outside the wheel's .libs
A passing run shows a $ORIGIN-relative RUNPATH, a clean import, and a manylinux_2_28 tag with no host libraries leaking in. If auditwheel show reports a lower platform tag, your GDAL was built against a newer glibc than the policy allows — pin the build image as described in manylinux2014 vs musllinux for spatial libs.
Pitfalls & Alternatives
Mixing a conda/Pixi prefix with a pip virtualenv. Activating a venv on top of Pixi puts two interpreters and two library sets on the path; find_package then resolves GDAL from whichever appears first. Use python -m build --no-isolation inside the Pixi environment and never layer a venv over it.
Trusting system GDAL because “it imports locally.” A wheel that imports on the build box because /usr/lib/libgdal.so happens to exist will throw ImportError: libproj.so.25: cannot open shared object file: No such file or directory on a clean runner. Always scope CMake to $PIXI_ENV_PREFIX and verify with auditwheel show, not a local import.
Leaving pip’s binary fallback enabled. Without PIP_NO_BINARY = ":all:", pip can swap a pinned C-library for a precompiled wheel mid-resolution, producing a ModuleNotFoundError: No module named 'pyproj._network' when pyproj ends up built against a different PROJ ABI than the one you pinned. This is a special case of the broader ABI problem in how to fix ABI version mismatch in GDAL wheels.
For fast CI triage, map the failure signature straight to the fix:
| CI/CD error signature | Root cause | Exact fix |
|---|---|---|
CMake Error: Could not find a package configuration file provided by "GDAL" |
CMake searched system /usr/lib instead of the Pixi env. |
Add CMAKE_PREFIX_PATH: "$env{PIXI_ENV_PREFIX}" to CMakePresets.json; confirm GDAL_ROOT. |
ImportError: libproj.so.25: cannot open shared object file: No such file or directory |
Missing RPATH in the compiled extension. |
Set CMAKE_INSTALL_RPATH_USE_LINK_PATH=ON; run the auditwheel/delocate repair. |
scikit-build-core: Ninja not found |
ninja absent or $PATH not refreshed. |
Pin ninja = ">=1.11"; run pixi install; set CMAKE_GENERATOR = "Ninja". |
ModuleNotFoundError: No module named 'pyproj._network' |
pyproj built against a mismatched PROJ ABI. |
Pin proj and libproj to identical minor versions; pixi run clean && pixi run build-wheel. |
auditwheel: cannot find libgdal.so.34 |
manylinux policy mismatch or missing LD_LIBRARY_PATH during repair. |
Use manylinux_2_28_x86_64; pass LD_LIBRARY_PATH=$PIXI_ENV_PREFIX/lib to auditwheel repair. |
To run this across linux-64, osx-arm64, and win-64 in parallel, drive the task from a matrix and cache the resolved environment — prefix-dev/setup-pixi@v0.8.0 with cache: true cuts resolution from roughly 45s to 8s; enable cache-write only on main to avoid feature branches poisoning the cache. The caching mechanics are detailed in how to set up build caching for C-extensions.
Related
- Environment Isolation with Pixi and Conda — the parent guide covering channel priority, lockfiles, and the isolation model this page builds on.
- Fixing CMake find_package for PROJ — deeper fix for the
Could not find a package configuration fileerror when CMake misses the Pixi prefix. - How to set up build caching for C-extensions — parallelize and cache the
pixi run build-wheelstep across the platform matrix. - Managing shared library paths in manylinux — what
auditwheel repairrewrites and how$ORIGINRPATHs resolve at import time.