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.

Further Reading

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