Reproducible Conda Environments for Spatial CI
Teams that resolve the spatial stack through conda-forge get the C libraries and their Python bindings solved together, which sidesteps the ABI mismatches that plague mixed apt-and-wheel installs — but only if the environment is locked. This guide sits beneath containerized GIS test runtimes and shows how to turn a conda environment.yml from a re-solving, drifting spec into a byte-reproducible lockfile that produces the same GDAL, GEOS and PROJ on every CI run and every developer machine. The failure this prevents is subtle: an environment.yml with loose version ranges re-solves on each run, so two builds a week apart can carry different GEOS builds and disagree on a topology verdict without any code change.
Why environment.yml alone is not reproducible
A plain environment.yml lists what you want, not what the solver chose. Each time conda resolves it, it picks the newest compatible builds from the channel at that moment, so the resolved GEOS or PROJ can change as conda-forge publishes updates. Reproducibility requires capturing the solved set — exact package builds, in dependency order, per platform — as a lockfile that CI installs verbatim rather than re-solving. That is what conda-lock produces.
Intent, solution, and installation are three different artefacts
The confusion that makes conda environments feel unreproducible comes from treating one file as all three things. An environment.yml states intent — what you want and from where. A solve turns that intent into a solution: exact builds, exact versions, for one platform. Installation then materialises that solution. Only the middle artefact is reproducible, and only if it is committed.
Lock-strategy reference
| Artifact | Reproducible? | Cross-platform? | Use for |
|---|---|---|---|
environment.yml (ranges) |
No — re-solves | Yes | Human-editable source of intent |
conda list --explicit |
Yes, per platform | No | Single-OS pinning |
conda-lock multi-platform lock |
Yes | Yes | CI across Linux/macOS runners |
| Built container image | Yes | Per image | Fastest gate startup |
Step-by-step implementation
The workflow targets conda-lock, conda-forge and a Linux CI runner, with the same lock reusable locally.
Step 1 — Declare intent with a pinned channel
Pin the channel to conda-forge only and disable channel mixing so a package never resolves from an unexpected source.
# environment.yml — source of intent, not the lock
name: gis-test
channels: [conda-forge]
channel_priority: strict
dependencies:
- python=3.12
- gdal=3.9.2
- geos=3.12.1
- proj=9.4.1
- geopandas=0.14.*
- shapely=2.0.*
- pytest=7.*
Step 2 — Solve once and lock
pip install conda-lock
conda-lock lock -f environment.yml -p linux-64 -p osx-arm64
# produces conda-lock.yml with exact builds per platform — commit it
Step 3 — Install from the lock in CI
# .github/workflows/spatial.yml
- uses: mamba-org/setup-micromamba@v1
with:
environment-file: conda-lock.yml # install the lock, never re-solve
environment-name: gis-test
Step 4 — Assert the solved stack at runtime
A session fixture fails fast if the environment CI actually built does not match the pinned versions — the same guard the containerized runtimes work uses for Docker images.
# conftest.py
import pytest, shapely, pyproj
from osgeo import gdal
@pytest.fixture(scope="session", autouse=True)
def assert_conda_stack():
assert shapely.geos_version == (3, 12, 1)
assert pyproj.proj_version_str.startswith("9.4.1")
assert gdal.__version__.startswith("3.9.2")
Why the platform matters more here than elsewhere
A lock file is per-platform, and that constraint has sharper consequences for spatial work than for ordinary Python. The compiled stack differs between a developer’s machine and the runner in ways that are invisible until a geometry result changes: different compilers, different optimisation flags, occasionally a different upstream patch level of GEOS carried by the same package version.
The workable arrangement is to solve for every platform the team actually uses and commit all of the resulting locks, then select by platform at install time. Solving only for the runner’s platform saves a moment and gives developers an environment nobody verified; solving only for the developer’s platform gives a gate running on something the lock never described.
| Symptom | Usual cause | Fix |
|---|---|---|
| Works locally, geometry differs in CI | Locks solved for one platform only | Solve and commit a lock per platform |
| Lock file changes on every solve | Loose ranges in the intent file | Constrain the intent, re-solve deliberately |
| Install is slow despite the lock | Solver still running | Install from the lock, never from intent |
| A package version differs from the lock | Something installed outside conda afterwards | Assert the resolved stack at start-up |
| The lock cannot be solved at all | Conflicting pins across the spatial stack | Relax the binding, never the C library |
The last row of the table is worth expanding, because it is where teams make the wrong trade under pressure. When a solve fails, the temptation is to relax the C library pin — allowing a range on GEOS or PROJ — because that usually resolves the conflict immediately. It also reintroduces exactly the non-determinism the lock exists to remove. The correct move is to relax the binding version instead, or to accept an older stack until the conflict clears upstream, because the binding’s exact version rarely changes a geometric result and the engine’s always can.
Verification pattern
Confirm the lock installs identically by resolving the environment on a clean machine and diffing the explicit package list; an empty diff proves reproducibility.
micromamba create -n verify -f conda-lock.yml
micromamba run -n verify conda list --explicit | sha256sum
# The hash must match across machines and runs
Failure modes and edge cases
- Ranges instead of a lock.
geos>=3.12re-solves as conda-forge publishes builds; commitconda-lock.yml, not the looseenvironment.yml, for CI. - Channel mixing. Without
channel_priority: strict, a package can resolve fromdefaultswith a different GEOS build than conda-forge; pin the channel. - Platform-specific lock on the wrong runner. A
linux-64explicit lock fails on anosx-arm64runner; useconda-lock’s multi-platform lock for mixed fleets. - Stale lock after an intent change. Editing
environment.ymlwithout re-runningconda-lockleaves CI installing the old solved set; regenerate the lock in the same commit. - Micromamba vs conda solver drift. Solving with one tool and installing with another can differ; standardize on one resolver and lock with it.
Where conda beats a wheel-based image, and where it does not
Conda earns its place when the spatial stack is the hard part of the environment: it resolves the compiled libraries and the Python bindings together, from one channel, with one solver, which removes the class of problem where a wheel’s bundled engine and a system package disagree. For a data-science-shaped project with GDAL, rasterio, PROJ, and a scientific stack alongside them, nothing else resolves that cleanly.
It costs image size and start-up time, and those costs land on every CI run. A slim wheel-based image starts faster and pulls less, which matters when the gate has a five-minute budget and the runner is cold. Where the spatial dependencies are few and the wheels are well-maintained, the wheel route is the cheaper answer and the reproducibility gap is closed by hash-pinning rather than by a solver.
A reasonable rule: use conda when the environment is the difficulty, and wheels when the environment is routine and speed is the constraint. What is not reasonable is mixing the two in one environment. Installing a spatial package with pip into a conda environment that already provides its C library produces two engines and an ABI question, which is the least debuggable configuration available.
Refreshing the lock without losing the guarantee
A lock is not permanent, and the refresh should be a scheduled, reviewable event rather than a reaction to a broken build. Re-solve on a cadence, commit the resulting lock as its own change, and run the full suite against it before it becomes the pinned lock. Because the diff of a lock file is unreadable, the review that matters is the test run rather than the file — which is why the refresh should never be bundled into a change that also alters code.
Conclusion
Reproducible conda environments for spatial CI come from locking the solved stack, not the loose spec: pin the channel, solve once with conda-lock, install the lock verbatim in CI, and assert the versions at runtime. That turns conda-forge’s unified GDAL/GEOS/PROJ resolution into a deterministic gate runtime, free of the ABI mismatches that come from mixing package sources. For the alternative Docker-pinning path and the broader runtime picture, return to containerized GIS test runtimes.
Commit the lock files alongside the intent file so a reader can see both what was asked for and what was resolved.
Assert the solved stack at start-up as well, so a manual change inside a running environment cannot go unnoticed.
Treat a lock refresh as its own change, never bundled with code.