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.

Solver-backed geospatial build environment, from manifest to tagged wheel A pixi.toml manifest pinned to the conda-forge channel feeds the pixi solver, which resolves one dependency graph per platform into pixi.lock. The default and build environments share the same solve-group, so both resolve libgdal and proj to identical builds. The activated environment prefix exports CMAKE_PREFIX_PATH and PKG_CONFIG_PATH, so find_package(GDAL) inside scikit-build-core or cibuildwheel resolves to the conda-managed binary; the compiled extension links the locked SONAMEs, and an auditwheel or delocate repair vendors them into a tagged manylinux wheel. RESOLVE & LOCK COMPILE & REPAIR 1 pixi.toml manifest + conda-forge channel channels = [conda-forge] · channel-priority = strict 2 pixi solver resolves one dependency graph for every listed platform 3 pixi.lock — per-platform locked graph linux-64 · osx-arm64 · win-64 pinned to exact builds 4 Two environments, one solve-group default → lean runtime · build → + scikit-build-core, cibuildwheel 5 Activated prefix exports the toolchain paths CMAKE_PREFIX_PATH · PKG_CONFIG_PATH → $PIXI_ENV_PREFIX 6 find_package(GDAL) resolves into the prefix scikit-build-core / cibuildwheel read the conda-managed binary 7 Compiled .so links the locked SONAMEs libgdal.so.34 · libproj.so — the same build CI tested 8 auditwheel / delocate → tagged wheel vendors the locked SONAMEs · manylinux_2_28_x86_64

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-feature solve-group semantics behave inconsistently on older releases. Install once per runner with curl -fsSL https://pixi.sh/install.sh | bash and 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: libgdal 3.8.x, proj 9.4.x, geos 3.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) or delocate>=0.11 (macOS) to vendor the locked SONAMEs into the final artifact.
  • Lock file: a committed pixi.lock. Without it, pixi install re-solves and you lose the reproducibility guarantee. CI must run pixi install --locked so 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 stray defaults build of libgdal can 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 as cibuildwheel and scikit-build-core resolving only in the build environment. The default environment stays lean for runtime validation, while the shared solve-group = "default" guarantees both environments resolve libgdal/proj to the same build — you validate against exactly what you compiled against.
  • Prefix injection. [activation.env] maps $PIXI_ENV_PREFIX into the standard CMake and pkg-config search paths, so find_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 translate pyproject.toml into 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

  1. Initialise the manifest. Scaffold the project and let Pixi create the pixi.toml skeleton:

    pixi init geospatial-build-env --channel conda-forge
    cd geospatial-build-env
    
  2. 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"
    
  3. Isolate build tooling in a feature. Keep cibuildwheel and the backend out of the runtime environment:

    pixi add --feature build "scikit-build-core>=0.9" "cibuildwheel>=2.18"
    
  4. Wire the prefix into the toolchain. Add the [activation.env] block shown above so CMake and pkg-config search the locked prefix. Confirm activation exports them:

    pixi run env | grep -E "CMAKE_PREFIX_PATH|PKG_CONFIG_PATH"
    
  5. Solve and commit the lock file. Resolve the per-platform graph and check pixi.lock into version control alongside pixi.toml:

    pixi install
    git add pixi.toml pixi.lock && git commit -m "Pin geospatial build environment"
    
  6. Define reproducible tasks. Replace ad-hoc conda activate sequences 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"
    
  7. Build the wheel inside the sandbox. Invoke the task so the compiler inherits the locked LD_LIBRARY_PATH / DYLD_FALLBACK_LIBRARY_PATH from the activated environment:

    pixi run build-wheel
    
  8. Repair against the locked SONAMEs. Vendor the exact libgdal/proj the 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.

Three Environments, Not One

A spatial project that treats “the environment” as a single thing eventually ships a wheel that only works where it was built. There are three, they have different contents, and keeping them separate is what makes the artifact honest.

The build, test and development environments and what each must contain Three environments. The build environment holds compilers, CMake, headers and the native libraries, and is pinned by a lock file. The test environment holds only an interpreter and the wheel, with no geospatial libraries at all, because its job is to prove the wheel is self-contained. The development environment holds everything plus editors, notebooks and analysis packages, and is the least constrained. A note records that using the build environment to test is the single most common way a broken wheel passes. build c-compiler · cxx-compiler cmake · ninja · pkg-config gdal · proj · geos + headers scikit-build-core pinned by the lock file reproduced exactly in CI test a bare interpreter the wheel under test pytest, and nothing else no gdal, no proj, no geos no PROJ_DATA, no GDAL_DATA its whole purpose is to have nothing development everything in the build env plus notebooks and plotting plus linters and debuggers plus whatever helps the least constrained, and never the one that validates testing in the build environment is the single most common way a wheel that bundles nothing passes every check pixi's named environments make the split cheap: one manifest, one lock, three feature sets

Tools that support multiple named environments from one manifest make this split nearly free, which is a strong argument for using them. The build and development environments can share a feature set, while the test environment deliberately declares almost nothing — and because it comes from the same lock file, it is as reproducible as the others.

Isolation Failures Worth Knowing

Isolation breaks in a small number of characteristic ways, and each leaves a recognisable trace.

Four ways environment isolation leaks in a spatial build Four leaks. An activated prefix leaves variables like GDAL_DATA and PROJ_DATA set, so the build or test sees data it did not ship. A pip install inside a conda environment layers a vendored wheel over a conda native library. Build isolation starts a fresh environment that does not inherit the activated prefix, so a build that works interactively fails in CI. And a system package manager installs libraries whose headers the build finds ahead of the pinned ones. activation leaves data variables set GDAL_DATA and PROJ_DATA point into the prefix, so a wheel missing its data still works — until a user installs it pip inside a conda environment a vendored wheel sits on top of a conda libgdal; two GDALs load, and which wins depends on import order build isolation does not inherit the prefix the backend runs in a fresh environment; a build that works after activation fails in CI unless variables are exported system packages shadow the pinned ones an apt-installed libgdal-dev supplies headers the build finds first, so the wheel links a version nobody pinned

The first of those is worth a habit rather than a fix: unset GDAL_DATA and PROJ_DATA in every test invocation, unconditionally. It costs nothing when they are absent and closes the most common route by which a wheel appears to carry data it does not.

Optimization & Edge Cases

  • Cache the solved environment in CI. Key a cache on the hash of pixi.lock and restore ~/.cache/rattler plus the .pixi/envs directory. 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. Drop win-64 if 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 musllinux wheels. 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-arm64 cannot be solved-and-built from an x86_64 macOS 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.

Frequently Asked Questions

Can I use pip inside a conda environment for a spatial project?

For pure-Python packages, yes. For anything that vendors native libraries, no — a PyPI rasterio wheel installed on top of a conda libgdal loads two GDALs into one process, and which one serves a given symbol depends on import order. Let one installer own the native layer and use the other only above it.

Why does the build work after activation but fail in CI?

Because build isolation starts the backend in a fresh environment that does not inherit an activated prefix. Anything the build needs must arrive as an exported variable or be configured in the manifest, which is the more durable answer: settings that live in the project file work identically on a laptop, in CI and inside a container.

Should the test environment really contain no geospatial packages?

Yes, and it is the point of having a separate one. Its job is to prove that the wheel carries its own libraries and data, and any GDAL, PROJ or PROJ_DATA present in it can satisfy the wheel’s needs by accident. An environment that contains nothing is the only one where a passing import means what you want it to mean.

How do these environments relate to the container the wheels are built in?

They are complementary. The container fixes the operating system, the libc and the toolchain baseline; the environment fixes the exact package versions inside it. A project that pins one and not the other has a reproducibility gap — usually the container, floating on a latest tag while the lock file is scrupulously maintained.

Is there a downside to keeping several named environments?

A little more solve time and a larger lock file, both negligible. The real cost is discipline: someone eventually adds a convenient package to the test environment to make a test pass, and the isolation quietly stops meaning anything. Reviewing changes to that environment’s feature list with the same care as a dependency bump keeps it honest.