Environment Isolation with Pixi and Conda
Geospatial Python packages carry a compilation burden that ordinary pure-Python projects never face: libraries such as GDAL, PROJ, rasterio, and pyproj depend on tightly coupled C/C++ binaries, system headers, and strict ABI alignment, so a venv + pip workflow routinely fractures into non-deterministic builds, silent ABI mismatches, and flaky CI matrices. This chapter sits under the Modern Python Build Tooling & Wheel Configuration reference and owns the foundational layer of that pipeline — provisioning a reproducible, solver-backed C/C++ toolchain and spatial-library stack from a lock file before a single wheel is built. It targets Pixi 0.27+, conda-forge channels, Python 3.10–3.12, GDAL 3.8.x, and PROJ 9.4.x, and it shows how a pixi environment lock file pins the compiler, headers, and native libraries to exact builds so the binary you test in CI is the binary you ship. For the fully worked end-to-end manifest, see configuring pixi environments for wheel building.
The isolation layer acts as a hermetic build sandbox: the compiler toolchain, the C-library headers, and the Python interpreter all originate from the same solver transaction, eliminating the “works on my machine” syndrome caused by OS package managers injecting mismatched .so or .dylib files into the linker path. This boundary keeps environment provisioning orthogonal to wheel metadata — the manifest and backend declarations covered in mastering pyproject.toml for spatial wheels stay separate from the toolchain that compiles them, preventing configuration bleed between build, test, and deployment stages.
Prerequisites & Environment
Pin every moving part before writing a single compiler flag. The whole value of solver-backed isolation collapses if the solver itself, the channel, or the platform list drifts between machines.
- Pixi: 0.27 or newer.
channel-priority = "strict"and multi-featuresolve-groupsemantics behave inconsistently on older releases. Install once per runner withcurl -fsSL https://pixi.sh/install.sh | bashand pin the version in CI. - Channel: conda-forge only. Mixing
defaults(the Anaconda channel) with conda-forge is the single most common source of ABI contamination for spatial stacks, because the two channels build against different GDAL/PROJ ABIs. - Python: 3.10–3.12, declared as a bounded range (
>=3.10,<3.13) so the solver never silently jumps to an interpreter your extension was not tested against. - Native libraries:
libgdal3.8.x,proj9.4.x,geos3.12.x. These are the packages whose SONAMEs your compiled extension binds to; whether you bundle them into the wheel or resolve them at runtime is decided in vendoring PROJ and GDAL vs system libraries. - Build tooling:
scikit-build-core>=0.9,cmake>=3.28,ninja>=1.11,cibuildwheel>=2.18. The CMake-to-backend bridge itself is documented in integrating CMake with scikit-build-core. - Repair tooling:
auditwheel>=6.0(Linux) ordelocate>=0.11(macOS) to vendor the locked SONAMEs into the final artifact. - Lock file: a committed
pixi.lock. Without it,pixi installre-solves and you lose the reproducibility guarantee. CI must runpixi install --lockedso a drifted lock file fails the job rather than silently re-solving.
This chapter focuses exclusively on guaranteeing that libgdal, proj, sqlite, and their transitive dependencies resolve identically before pip wheel or python -m build ever executes. The wider problem domain — what the parent reference solves — is summarised in the geospatial CI/CD engineering reference.
Core Configuration
The pixi.toml manifest is the single source of truth for the build environment. Channel priority and strict version pinning are non-negotiable for spatial packages: conda-forge must be explicitly prioritised to avoid ABI conflicts between system-provided libraries and compiled Python extensions, and build-time dependencies must be isolated from runtime requirements so they never bloat the final wheel.
[project]
name = "geospatial-build-env"
channels = ["conda-forge"]
platforms = ["linux-64", "osx-arm64", "win-64"]
channel-priority = "strict"
[dependencies]
python = ">=3.10,<3.13"
libgdal = "3.8.*"
proj = "9.4.*"
geos = "3.12.*"
numpy = ">=1.26,<2.0"
cmake = ">=3.28"
ninja = ">=1.11"
pkg-config = ">=0.29"
[pypi-dependencies]
pyproj = ">=3.6"
shapely = ">=2.0"
[feature.build.dependencies]
scikit-build-core = ">=0.9"
cibuildwheel = ">=2.18"
[environments]
default = { features = [], solve-group = "default" }
build = { features = ["build"], solve-group = "default" }
[activation.env]
CMAKE_PREFIX_PATH = "$PIXI_ENV_PREFIX"
PKG_CONFIG_PATH = "$PIXI_ENV_PREFIX/lib/pkgconfig"
Three directives carry the design:
- Channel enforcement.
channel-priority = "strict"(the Pixi default, made explicit here) forces the solver to take each package from the first channel that provides it —conda-forge— so a straydefaultsbuild oflibgdalcan never sneak in and break the ABI of an extension compiled against the conda-forge build. - Feature isolation. The
[feature.build.dependencies]block keeps heavy tooling such ascibuildwheelandscikit-build-coreresolving only in thebuildenvironment. Thedefaultenvironment stays lean for runtime validation, while the sharedsolve-group = "default"guarantees both environments resolvelibgdal/projto the same build — you validate against exactly what you compiled against. - Prefix injection.
[activation.env]maps$PIXI_ENV_PREFIXinto the standard CMake andpkg-configsearch paths, sofind_package(GDAL)resolves to the conda-managed binary rather than a host fallback. This is the hook the scikit-build-core backend relies on to translatepyproject.tomlinto CMake invocations that find the right headers.
Unlike PyPI wheels that ship statically linked dependencies, conda packages rely on dynamic linking with RPATHs pointing into the environment prefix. That architecture is what demands strict ABI alignment across the whole dependency tree — and what makes the prefix-injection block load-bearing rather than cosmetic.
Step-by-Step Implementation
-
Initialise the manifest. Scaffold the project and let Pixi create the
pixi.tomlskeleton:pixi init geospatial-build-env --channel conda-forge cd geospatial-build-env -
Pin the native stack. Add the geospatial C-libraries and the toolchain with bounded versions:
pixi add "python>=3.10,<3.13" "libgdal=3.8.*" "proj=9.4.*" "geos=3.12.*" \ "cmake>=3.28" "ninja>=1.11" "pkg-config>=0.29" -
Isolate build tooling in a feature. Keep
cibuildwheeland the backend out of the runtime environment:pixi add --feature build "scikit-build-core>=0.9" "cibuildwheel>=2.18" -
Wire the prefix into the toolchain. Add the
[activation.env]block shown above so CMake andpkg-configsearch the locked prefix. Confirm activation exports them:pixi run env | grep -E "CMAKE_PREFIX_PATH|PKG_CONFIG_PATH" -
Solve and commit the lock file. Resolve the per-platform graph and check
pixi.lockinto version control alongsidepixi.toml:pixi install git add pixi.toml pixi.lock && git commit -m "Pin geospatial build environment" -
Define reproducible tasks. Replace ad-hoc
conda activatesequences with declared tasks that always run inside the locked environment:[tasks] install-build = "pixi run --environment build pip install -e ." build-wheel = "pixi run --environment build python -m build --wheel" test-spatial = "pixi run pytest tests/ --cov=src" -
Build the wheel inside the sandbox. Invoke the task so the compiler inherits the locked
LD_LIBRARY_PATH/DYLD_FALLBACK_LIBRARY_PATHfrom the activated environment:pixi run build-wheel -
Repair against the locked SONAMEs. Vendor the exact
libgdal/projthe solver provisioned, following the policy rules in manylinux and manyarm Docker base images:pixi run --environment build auditwheel repair dist/*.whl -w wheelhouse/
Verification
Confirm three things: the environment is byte-reproducible, the wheel imports, and no host library leaked into the binary.
# 1. Fail fast if the committed lock file drifted from the manifest
pixi install --locked
# 2. Confirm the spatial stack imports inside the locked environment
pixi run python -c "import pyproj, shapely; print(pyproj.__version__, shapely.__version__)"
Inspect the dynamic linkage of the compiled extension — every spatial library must resolve into the environment prefix or the vendored .libs, never /usr/lib:
# Linux
pixi run ldd build/lib*/spatial_core*.so | grep -E "libgdal|libproj"
# expected: libgdal.so.34 => /…/.pixi/envs/build/lib/libgdal.so.34
# macOS
pixi run otool -L build/lib*/spatial_core*.so | grep -E "libgdal|libproj"
Finally, audit the repaired wheel to confirm it carries the locked SONAMEs and tags to the expected platform:
auditwheel show wheelhouse/*.whl
# expected: "… is consistent with the following platform tag: manylinux_2_28_x86_64"
A clean run shows GDAL and PROJ resolving inside the prefix, an import that succeeds, and an auditwheel show that names a single manylinux policy with no external references outside the vendored set.
Optimization & Edge Cases
- Cache the solved environment in CI. Key a cache on the hash of
pixi.lockand restore~/.cache/rattlerplus the.pixi/envsdirectory. Because the lock file fully determines the graph, a cache hit skips the entire solve-and-download phase — the dominant cost on a cold GDAL runner. This dovetails with the parallel matrix and hydration patterns in async build execution and cache strategies. - Prune the platform matrix. Each platform in
platforms = [...]adds a full solve and a locked sub-graph. Dropwin-64if you do not ship Windows wheels; every platform you keep is a platform CI must validate. - musl vs glibc. conda-forge builds against glibc, so a Pixi-provisioned toolchain does not produce
musllinuxwheels. If you need Alpine/musl artifacts, that target belongs to the manylinux/musllinux image path rather than the conda solver — see manylinux2014 vs musllinux for spatial libs. - Cross-compilation gotcha.
osx-arm64cannot be solved-and-built from anx86_64macOS runner through Pixi alone; the native toolchain conda provides targets the host arch. Build arm64 wheels on Apple-silicon runners (or the appropriate emulated image) rather than expecting the solver to cross-compile. - Keep the artifact structure honest. Once the wheel is built and repaired, its
.dist-info, licence files, and architecture-specific extension placement still have to satisfy registry validation — covered in build artifact structuring and packaging.
Troubleshooting
LibMambaUnsatisfiableError: Encountered problems while solving: nothing provides __cuda needed by libgdal — the solver pulled a CUDA-enabled libgdal variant whose virtual __cuda package the runner cannot satisfy. Root cause: an unconstrained libgdal spec let the solver pick a GPU build. Fix: constrain the build string to the CPU variant, e.g. libgdal = { version = "3.8.*", build = "*cpu*" }, or set CONDA_OVERRIDE_CUDA="" so no CUDA virtual package is assumed, then re-solve.
The lock file is not up-to-date with the project. Run pixi install to update it. — pixi install --locked ran in CI against a pixi.lock that no longer matches pixi.toml. Root cause: a dependency was edited in the manifest without re-solving and committing the lock. Fix: run pixi install locally, commit the regenerated pixi.lock, and keep --locked in CI so the drift keeps failing fast until the lock is current.
CMake Error: Could NOT find GDAL (missing: GDAL_LIBRARY GDAL_INCLUDE_DIR) during an isolated build — find_package(GDAL) cannot see the conda-provisioned binary. Root cause: the build ran outside the activated environment, so CMAKE_PREFIX_PATH never pointed at $PIXI_ENV_PREFIX. Fix: run the build through a pixi run --environment build … task (never a bare python -m build), and verify the activation block exports CMAKE_PREFIX_PATH. The deeper find_package repair for the related PROJ case is walked through in fixing CMake find_package for PROJ.
ImportError: libgdal.so.34: cannot open shared object file: No such file or directory at wheel import time — the extension links the conda libgdal SONAME but the wheel never bundled it. Root cause: the artifact was built inside the Pixi prefix but shipped without an auditwheel repair/delocate pass, so it depends on libraries only present on the build machine. Fix: repair the wheel to vendor the SONAME (auditwheel repair), then re-check with ldd that libgdal.so.34 resolves inside the wheel’s .libs. Hosts that deliberately rely on system libraries instead must satisfy the SONAME via shared library path resolution.
Related
- Configuring pixi environments for wheel building — the full worked manifest with platform overrides, compiler routing, and exact error-to-recovery walkthroughs.
- Mastering pyproject.toml for spatial wheels — the metadata and backend declarations that the isolated toolchain compiles.
- Integrating CMake with scikit-build-core — how the injected prefix feeds
find_package(GDAL)during the build. - Manylinux and manyarm Docker base images — the glibc-policy and base-image choices that decide whether the repair step succeeds.
- Vendoring PROJ and GDAL vs system libraries — the static-vs-dynamic decision behind whether the locked SONAMEs travel inside the wheel.