Dependency Resolution and Lockfiles for Spatial Environments

Reproducibility in a geospatial build stands or falls on the lock file: the exact GDAL, PROJ, GEOS, and interpreter versions must be pinned cryptographically, or the same pyproject.toml resolves to a different native ABI on every machine and the wheels drift. This guide sits under the Modern Python Build Tooling & Wheel Configuration reference and covers how solvers pin the native spatial stack — conda-style resolution, the lock-file formats, multi-platform locking, and the discipline that keeps a build environment identical from a laptop to a CI runner. It targets pixi 0.30+, conda-lock 2.5+, conda/mamba, conda-forge GDAL 3.8.x/PROJ 9.3.x, and complements the isolation model in Environment Isolation with Pixi and Conda.

From loose version ranges to a per-platform locked environment Loose version ranges in a manifest feed a solver, which resolves one consistent set of native and Python packages and writes a lock file with cryptographic hashes per platform. Each CI runner installs directly from the lock, skipping resolution, so every machine materializes the identical GDAL, PROJ, and interpreter versions. manifest gdal >=3.8,<3.9 solver one consistent set lock file hashes · per platform CI runner A install from lock CI runner B identical env

Prerequisites & Environment

  • A solver: pixi 0.30+ (which embeds a fast resolver) or conda-lock 2.5+ over conda/mamba.
  • The conda-forge channel with channel-priority: strict — mixing channels is the top cause of an unsolvable or ABI-inconsistent spatial environment.
  • The set of target platforms declared up front (linux-64, osx-arm64, win-64), because a lock is only reproducible on platforms it was solved for.
# Confirm a solver is available
pixi --version || conda-lock --version

Core Configuration

A lock file records three things a loose manifest cannot: the exact build string of every native package (so libgdal 3.8.5 h... _0 is pinned, not just 3.8), a content hash for verification, and a per-platform solution so osx-arm64 and linux-64 each get a coherent set. The two dominant tools differ in scope:

Tool Lock format Scope Best for
pixi pixi.lock (multi-platform, unified) conda + PyPI in one solve, task runner Project-local build/dev environments
conda-lock conda-<plat>.lock per platform conda packages, renders to explicit specs Pinning an existing environment.yml for CI

The head-to-head trade-offs — unified versus per-platform locks, PyPI integration, and CI ergonomics — are worked through in pixi vs conda-lock for reproducible spatial envs. Either way, the lock is the artifact you commit and install from; the manifest is only its input.

Step-by-Step Implementation

  1. Declare ranges and channels in the manifest, pinning the native minor versions so the ABI is stable:

    # pixi.toml (excerpt)
    channels = ["conda-forge"]
    platforms = ["linux-64", "osx-arm64", "win-64"]
    [dependencies]
    gdal = ">=3.8,<3.9"
    proj = ">=9.3,<9.4"
    python = ">=3.10,<3.13"
    
  2. Solve once and commit the lock:

    pixi lock            # writes pixi.lock for every declared platform
    git add pixi.lock    # the lock is a committed artifact, not a build output
    
  3. Install from the lock everywhere — never re-solve in CI:

    pixi install --locked     # fails if pixi.lock is stale, guaranteeing fidelity
    
  4. Regenerate deliberately. Update the lock only via an explicit pixi update, reviewed like any dependency bump, so native versions never change by accident.

Verification

# 1. The locked environment is byte-consistent with the manifest
pixi install --locked && echo "lock is current"
# expected: lock is current  (non-zero exit if pixi.lock drifted)
# 2. The exact native versions are pinned, not just ranges
grep -E 'gdal|proj' pixi.lock | grep -oE '3\.[0-9]+\.[0-9]+' | sort -u | head
# expected: single resolved versions like 3.8.5 and 9.3.1
# 3. The same lock solves for every target platform
grep -c 'platform:' pixi.lock
# expected: one entry per declared platform (linux-64, osx-arm64, win-64)

Matching versions across a --locked install and one resolved GDAL/PROJ version per platform confirm the environment is reproducible.

Optimization & Edge Cases

  • Cache the solved environment in CI. Restoring a materialized prefix keyed on the lock hash cuts install time dramatically — the caching pattern in async build execution and cache strategies.
  • Lock covers the build, not the wheel. A conda lock pins the build environment; the distributable wheel still needs auditwheel/delocate repair to be relocatable off that environment.
  • Pin libgdal explicitly, not just gdal. The Python gdal package and the C libgdal can float independently on conda-forge; pin both to keep the ABI fixed.

Troubleshooting

LibMambaUnsatisfiableError on solve. A channel mix or an over-tight pin made the graph unsolvable. Set channel-priority: strict, loosen the least-important pin, and re-solve.

A --locked install fails after editing the manifest. That is the guardrail working: the lock is stale. Run pixi update (or conda-lock) intentionally and commit the new lock.

Different results on macOS vs Linux. The lock was solved for only one platform. Declare every target platform before solving so the lock carries all of them.

Why the Spatial Stack Resolves Differently

Most Python dependency problems are graph problems over Python packages. The geospatial stack is a graph problem over native packages with Python bindings attached, and the difference changes what a solver has to prove. When rasterio, fiona, pyproj and shapely are installed together, four independent binding packages each link against a shared library — libgdal, libproj, libgeos — and every one of those libraries has a soname that encodes an ABI. A solution is only valid if all four bindings agree on the same soname, not merely on a compatible Python version range.

This is why a set of wheels that pip happily installs can segfault, and why the conda-forge ecosystem exists in the form it does. On PyPI, each binding vendors its own copy of the native library, so agreement is achieved by isolation: four copies of PROJ in one environment, each private to its binding. In conda-forge, the native libraries are packages in their own right, so agreement is achieved by the solver: one libproj, and every binding constrained to the build that matches it. Both models work. Mixing them — a conda libgdal plus a PyPI rasterio wheel that vendored a different one — produces an environment where two versions of GDAL are loaded into one process, and the symbols that win are decided by load order.

Two resolution models for the same four spatial bindings On the left, the PyPI model: rasterio, fiona, pyproj and shapely each vendor their own private copy of the native libraries, so four copies of PROJ and GDAL exist in one environment. On the right, the conda-forge model: the same four bindings all link a single shared libgdal, libproj and libgeos package that the solver pins to one build string. Mixing the two models loads two versions of the same library into one process. vendored wheels (PyPI) shared native packages (conda-forge) rasterio fiona pyproj shapely libgdalcopy 1 libgdalcopy 2 libprojcopy 3 libgeoscopy 4 agreement by isolation — nothing is shared size cost, but no cross-package ABI contract rasterio fiona pyproj shapely libgdal 3.8.5 · libproj 9.3.1 · libgeos 3.12.1 one build string, pinned by the solver agreement by resolution — one ABI for the whole environment breaks the moment a PyPI wheel vendors its own a lock file records which model you chose and freezes the outcome without one, the same manifest resolves differently on every machine and every week

The lock file is where that choice becomes durable. It records not just versions but build strings — the conda-forge identifier that encodes the variant a package was compiled for, including the compiler, the Python ABI it targets, and the versions of the libraries it linked. Two libgdal 3.8.5 packages with different build strings are different binaries with different capabilities: one may have been built with the Arrow driver, the other without. A pin that stops at the version number does not distinguish them, which is exactly how a build that worked last month starts failing after a conda-forge rebuild that changed nothing you can see.

Multi-Platform Locking and the CI Contract

A lock that covers one platform is a lock for one machine. Spatial projects almost always need at least three — linux-64 for CI and containers, osx-arm64 for developer laptops, win-64 for the desktop GIS users who file the hardest bugs — and each of those needs its own coherent solution because the available builds differ per platform. Some drivers exist only on Linux; some versions never got a Windows build; the compiler variants differ everywhere.

Both dominant tools handle this, with different ergonomics. A unified pixi.lock carries every platform in one file, so a single artifact describes the whole project and git diff shows what changed across all of them at once. Per-platform conda-lock files are separate artifacts, which makes CI configuration slightly more explicit — the Linux job installs the Linux lock and nothing else — at the cost of keeping several files in step.

One manifest, one solve, many platforms, no re-solving downstream A single manifest is solved once on a maintainer machine or a scheduled job, producing a lock that contains a separate coherent solution for linux-64, osx-arm64 and win-64. Every downstream consumer — the CI build matrix, a developer laptop and a container image — installs from that lock with a locked flag that refuses to re-solve, so all three environments are identical by construction. manifest ranges + channels solve once maintainer or scheduled job linux-64 solution build strings pinned osx-arm64 solution build strings pinned win-64 solution build strings pinned CI matrix --locked laptop --locked container image --locked nothing downstream ever re-solves — a stale lock fails the job instead of silently drifting

The --locked flag is the contract, and it should be non-negotiable in CI. Its job is to fail when the manifest and the lock disagree, which converts a class of silent drift into a loud, early error at the cheapest possible moment. Without it, a developer who edits a version range and forgets to re-lock gets a CI run that resolves something new, builds successfully, and produces wheels linked against a GDAL nobody chose.

Two further habits pay for themselves. Update locks on a schedule rather than on demand — a weekly automated pixi update that opens a pull request keeps the diff small and reviewable, whereas six months of accumulated drift resolved in one go is a change nobody can assess. And keep the environment used to build wheels separate from the environment used to test them: the build environment needs compilers, headers and CMake; the test environment should look like a user’s machine and contain none of those, or you will never notice that a wheel depends on something only the build box had.

Reading a Solver Failure Without Guessing

Unsolvable environments are the most common friction point in a spatial project, and the error messages are long enough that most people skim them and start deleting pins at random. There is a faster method, because nearly every failure in this ecosystem falls into one of four shapes.

The first is a channel conflict. Two channels both publish gdal, built against different versions of the same dependencies, and the solver is free to mix them. The symptom is an unsatisfiable graph that mentions packages you never asked for; the fix is channel-priority: strict with conda-forge first, so a package available in the higher-priority channel is never taken from a lower one. This single setting resolves a large share of “it worked yesterday” reports, because a package appearing in a second channel changes the search space without any change on your side.

The second is an over-tight native pin. Pinning gdal ==3.8.5 and python ==3.12.4 simultaneously is asking for a specific build of GDAL compiled for a specific CPython patch, and such a build may simply not exist. Native packages are built per interpreter minor version, not per patch, so pin Python to a minor (>=3.12,<3.13) and GDAL to a minor (>=3.8,<3.9) and let the solver pick the patch and the build string. Pin the exact build only in the lock file, which is generated rather than hand-written.

The third is a platform-specific gap. The environment solves on Linux and fails on Windows because one package in the graph has no Windows build — common for smaller format drivers and for anything depending on a Unix-only system library. The message names the package but not the reason, so the diagnostic move is to solve for each platform separately and compare which one fails: the platform that fails first tells you where the gap is, and the usual answer is to make that dependency conditional on platform rather than universal.

The fourth is a Python-and-native mismatch introduced by mixing installers. A conda environment that later has pip install rasterio run inside it now contains a PyPI wheel with its own vendored GDAL sitting on top of a conda libgdal. Nothing fails at install time; the failure arrives later as a segfault or a driver that reports the wrong version. The rule that avoids it is to let one installer own the native layer: either everything from conda-forge, or everything from PyPI wheels, and use pip inside a conda environment only for pure-Python packages.

# Which platform is the graph actually failing on?
for p in linux-64 osx-arm64 win-64; do
  echo "== $p"; conda-lock --platform "$p" -f environment.yml --check-input-hash || true
done

A last habit worth adopting: when a solve fails, record the exact solver version alongside the error before changing anything. Solvers change their search strategy between releases, and an environment that becomes unsolvable after a pixi or mamba upgrade — with no change to your manifest — is a different problem from one you introduced, and the two need different responses.

Frequently Asked Questions

Can I use a conda environment for the build and still publish PyPI wheels?

Yes, and it is a common pattern — conda-forge provides an easy, consistent source of GDAL, PROJ and a matching compiler toolchain, and the wheel that comes out is repaired to be self-contained. The one rule is that the repair step must bundle the libraries out of the conda prefix and rewrite the load paths, or the wheel will reference a prefix that exists only on the build machine.

Why does the lock file change when I did not touch the manifest?

Because the solver’s inputs changed: conda-forge rebuilt a package, a new build string appeared, or a repodata patch altered a constraint. That is precisely the drift the lock exists to make visible. Review the diff — a changed build string with an unchanged version usually means a rebuild against a newer dependency, which is exactly the kind of change worth noticing before it reaches a release.

Should the lock file be committed to the repository?

Yes. It is a source artifact, not a build output: it is the thing that makes a build reproducible, and it must be reviewable in the same pull request as the manifest change that caused it. Generating it during CI defeats the purpose, because the environment the release was built in would then be unknowable after the fact.

How do I pin the interpreter itself?

Pin it in the manifest with an explicit range and let the lock fix the exact build. For wheel building this matters less than it looks — an abi3 wheel is built once against the lowest supported CPython — but for the build environment it matters a great deal, because a solver free to pick a newer Python may also pick differently-built native packages that target that Python’s ABI.

Does a lock file help when the project also publishes wheels?

Indirectly but substantially. The lock does not travel inside the wheel, yet it fixes the GDAL, PROJ and GEOS versions that the wheel is compiled and linked against, so it determines what ends up bundled. Two releases built from the same source with different locks produce wheels that behave differently, which is why the lock belongs in the same review as the source change that prompted it, and why the release notes should quote the versions it resolved.

How do I keep a Docker image in step with the lock file?

Copy the lock into the image and install from it as an early layer, before copying application code. The lock changes rarely and the code changes constantly, so that ordering means an ordinary code change reuses the cached environment layer instead of re-solving and re-downloading several hundred megabytes of native packages. Rebuilding the environment layer then becomes an explicit consequence of a lock change, visible in the build output.

Can a lock file pin the compilers as well as the libraries?

Yes, and for a wheel-building environment it should. The conda-forge compiler packages (c-compiler, cxx-compiler and the matching sysroot) are ordinary packages, so adding them to the manifest puts the toolchain under the same pin as GDAL and PROJ. That closes the last unpinned input in the build: without it, a runner image upgrade can change the compiler under a lock that swears the environment is reproducible. Treat the image digest and the lock hash as one unit of reproducibility, recorded together in the release notes.

Further Reading

  • pixi and conda-lock documentation for the authoritative lock-format semantics.