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

From pixi.toml manifest to a relocatable geospatial wheel The pixi.toml manifest feeds the pixi solver, which writes a cryptographically pinned pixi.lock and materializes an activated prefix holding the platform compilers plus libgdal and libproj. scikit-build-core with CMakePresets.json routes find_package into that prefix, then python -m build produces a build-tree wheel carrying absolute prefix RPATHs. A repair pass with auditwheel or delocate rewrites those RPATHs to $ORIGIN-relative paths, yielding a distributable wheel that imports on a clean runner. pixi.toml manifest · pins · channels pixi solver → pixi.lock activated prefix $PIXI_ENV_PREFIX scikit-build-core + CMakePresets.json compilers + libgdal / libproj python -m build --no-isolation build-tree wheel absolute prefix RPATH auditwheel / delocate rewrite RPATH distributable wheel $ORIGIN RUNPATH

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-isolation stops build from 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.

Features, Environments and Why the Split Helps

The manifest’s power for wheel building comes from separating features — named groups of dependencies — from environments, which are combinations of features. A spatial project gets three environments out of four small feature groups without duplicating a single pin.

Feature groups combined into build, test and development environments Four feature groups are defined: native, holding gdal, proj and geos; toolchain, holding the compilers, cmake and ninja; packaging, holding the build backend and repair tools; and analysis, holding notebooks and plotting libraries. The build environment combines native, toolchain and packaging. The test environment combines nothing but a bare interpreter and pytest. The development environment combines all four. Every pin is written once and resolved in a single solve. features — each pin written once nativegdal · proj · geos toolchaincompilers · cmake packagingbackend · auditwheel analysisnotebooks · plots environments — combinations, solved together build native + toolchain + packaging test python + pytest only dev all four features the test environment deliberately inherits nothing — that is what makes it a valid place to prove the wheel is self-contained and because all three come from one solve, they can never disagree about a version

The deliberate emptiness of the test environment is the part worth defending in review. It looks like an oversight — no GDAL, no PROJ — and it is the entire point: a wheel installed into it either carries its own libraries and data or fails, and either outcome is information.

Tasks Keep the Commands Honest

The second thing the manifest buys is a place to record the commands, which matters more than it sounds because it removes the gap between what CI runs and what a developer runs.

Tasks defined once in the manifest and invoked identically everywhere A single manifest defines three tasks: build-wheel, repair and smoke-test, each bound to a specific environment. A developer on a laptop, a CI job and a container build all invoke the same task names, so the commands cannot drift. Without tasks, the same three commands appear in a CI file, a README and a developer's shell history, and they diverge. [tasks] build-wheel (env: build) repair (env: build) smoke-test (env: test) a laptop pixi run build-wheel CI pixi run build-wheel a container build pixi run build-wheel the same string in all three places, because there is only one string

The practical payoff arrives the first time someone cannot reproduce a CI failure. With tasks there is nothing to reproduce — the command is identical — and the investigation starts at the actual difference, which is usually the environment rather than the invocation.

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.