Pinning Geospatial Dependencies with conda-lock

The reliable way to keep GDAL, PROJ, GEOS, rasterio, and geopandas byte-identical across a laptop, a CI runner, and a production worker is pinning geospatial dependencies with conda-lock: you declare a loose environment once, resolve it into a fully-pinned multi-platform lockfile, and install from that lock everywhere so the ABI-sensitive native libraries never drift. This page shows how to author the environment, generate the lock for Linux and macOS, consume it in CI and container builds, and verify the resolved versions with pyproj and rasterio.

When to Use This Pattern

  • Your spatial stack mixes native C/C++ libraries (libgdal, libproj, libgeos) whose ABIs must match the Python bindings compiled against them.
  • Developers on macOS and CI/production on Linux need the same resolved versions from one source of truth.
  • A pip install has silently upgraded GDAL between two builds and changed reprojection output.
  • You want the lockfile that the image build in Containerizing GDAL Workers with Docker installs from.

Why the Native Stack Needs a Real Lock

Plain pip was never designed for the geospatial problem. It resolves Python packages, but the versions that actually matter for spatial correctness — libgdal, libproj, libgeos — are C/C++ libraries that rasterio, pyproj, and shapely link against at the ABI level. Two things break when you let pip and manylinux wheels handle this. First, each wheel may bundle its own copy of the native library, so a single environment can end up with rasterio’s GDAL, fiona’s GDAL, and a system GDAL all coexisting, and nothing guarantees they agree. Second, the resolved versions are not truly frozen: a rebuild weeks later can pick up a newer wheel whose bundled PROJ selects a different datum grid, silently shifting coordinates.

conda-forge solves the first problem by building the whole stack against one shared set of native libraries, and conda-lock solves the second by resolving that stack exactly once into a hashed, multi-platform lockfile. The lock records not just gdal=3.8.4 but the specific build string and content hash, for every platform you name, so linux-64 in production and osx-arm64 on a developer’s laptop install from the same resolution. That single source of truth is what makes the boot-time assertions in Environment Parity for Spatial Pipelines meaningful — there is a canonical answer for what “correct” versions are.

Complete Working Example

Start with a human-authored environment file that lists only the packages you care about, from conda-forge (the channel that builds a coherent geospatial stack). Note that you pin intentions here — a minor floor — and let conda-lock resolve exact builds.

# environment.yml — the loose, human-maintained input
name: spatial-worker
channels:
  - conda-forge
  - nodefaults
dependencies:
  - python=3.11
  - gdal=3.8.4
  - proj=9.3.1
  - geos=3.12.1
  - libgdal-core=3.8.4
  - rasterio=1.3.9
  - geopandas=0.14.3
  - pyproj=3.6.1
  - shapely=2.0.3
  - prefect=2.14.21

Generate a multi-platform lockfile. The single conda-lock.yml resolves every dependency — including transitive native libraries — to an exact build hash for each platform you name.

# Install the tool into an isolated environment.
pipx install conda-lock

# Resolve environment.yml into one unified multi-platform lock.
conda-lock lock \
    --file environment.yml \
    --platform linux-64 \
    --platform osx-arm64 \
    --lockfile conda-lock.yml

# Optional: emit per-platform "rendered" locks for tools that want them.
conda-lock render --kind explicit --platform linux-64 conda-lock.yml

Install from the lock — never from environment.yml — so every machine materializes identical builds:

# Create the environment from the locked, hashed versions.
conda-lock install --name spatial-worker conda-lock.yml

# Or, inside a Dockerfile using micromamba:
#   micromamba create -y -n spatial-worker -f conda-lock.yml

A word on the workflow this creates. The loose environment.yml is what humans edit; the conda-lock.yml is a generated artifact you commit but never hand-edit. When you want to bump GDAL, you change the floor in environment.yml, re-run conda-lock lock, review the diff in the generated lock, and commit both. The diff is genuinely reviewable — you can see that a GDAL bump also pulled a new PROJ, which is exactly the transitive change that would otherwise sneak into production unnoticed. This is the same reason the image build in Containerizing GDAL Workers with Docker installs from the lock and not the loose file: the build must be a pure function of committed bytes.

Wire the same lock into CI so the resolved versions are proven before an image publishes:

# .github/workflows/lock-check.yml (excerpt)
jobs:
  verify-lock:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: mamba-org/setup-micromamba@v1
        with:
          environment-file: conda-lock.yml
          environment-name: spatial-worker
      - name: Fail if the lock is stale vs environment.yml
        run: |
          pipx install conda-lock
          conda-lock lock --file environment.yml \
            --platform linux-64 --platform osx-arm64 \
            --lockfile /tmp/fresh.yml
          diff conda-lock.yml /tmp/fresh.yml \
            || (echo "conda-lock.yml is out of date; re-run conda-lock" && exit 1)
      - name: Verify resolved native versions
        run: micromamba run -n spatial-worker python verify_versions.py

Multi-Platform Locking in Practice

The multi-platform capability is what makes conda-lock worth the ceremony for a spatial team. A single conda-lock.yml can carry resolved package sets for linux-64, osx-arm64, and linux-aarch64 side by side, each with its own build hashes, because the same GDAL version is compiled differently per platform. When a developer on an Apple-silicon Mac runs conda-lock install, the tool selects the osx-arm64 entries; when CI on an x86 Linux runner does the same, it selects linux-64. Both draw from one resolution event, so neither can drift independently.

This is a stronger guarantee than “we all use the same requirements.txt,” because a requirements file still re-resolves native wheels per platform at install time. With the lock, the resolution already happened and is frozen; installation is a download-and-extract of exact, hashed artifacts. The practical payoff shows up when a laptop reprojection and a production reprojection are compared and found identical — a property you can only assert when the PROJ build backing each is provably the same. That property is exactly what the parity guard in Environment Parity for Spatial Pipelines verifies at runtime, and what the full delivery pipeline in Deployment & CI/CD for Spatial Workers enforces before publishing an image.

One operational note: regenerate the lock on a machine or CI job whose base OS matches one of your target platforms, and always commit the regenerated lock in the same change as the environment.yml edit that prompted it. Splitting those across commits is how a stale lock sneaks into main, which the verify-lock job exists to catch.

Parameter & Option Reference

Option Type Default Spatial notes
--platform repeatable str host platform List every OS/arch you deploy so one lock serves dev + prod
--file path environment.yml The loose input; the source of truth for intent
--lockfile path conda-lock.yml The pinned output committed to the repo
--kind str lock Use explicit to render per-platform installer lists
channel order list conda-forge first Mixing defaults and conda-forge GDAL builds causes ABI clashes
--check-input-hash flag off Skips re-resolving when environment.yml is unchanged

Verification & Testing

A lockfile is only trustworthy once you confirm the runtime versions match what you pinned and that pyproj links the expected PROJ. The script below asserts each native version and prints the PROJ data directory, catching a stale or mixed-channel environment.

import rasterio
import pyproj
import shapely
from pyproj.datadir import get_data_dir

EXPECTED: dict[str, str] = {
    "gdal": "3.8.4",
    "proj": "9.3.1",
    "geos": "3.12.1",
}

def verify_versions() -> None:
    # rasterio reports the GDAL it linked against.
    gdal_ver: str = rasterio.__gdal_version__
    assert gdal_ver == EXPECTED["gdal"], f"GDAL {gdal_ver} != {EXPECTED['gdal']}"

    # pyproj reports the PROJ it bound to.
    proj_ver: str = pyproj.proj_version_str
    assert proj_ver == EXPECTED["proj"], f"PROJ {proj_ver} != {EXPECTED['proj']}"

    # GEOS backs shapely/geopandas spatial predicates; version is a 3-tuple.
    geos_ver: str = ".".join(str(n) for n in shapely.geos_version)
    assert geos_ver == EXPECTED["geos"], f"GEOS {geos_ver} != {EXPECTED['geos']}"
    assert get_data_dir(), "PROJ data dir empty; grids will be missing"

    print(f"locked stack OK — GDAL {gdal_ver}, PROJ {proj_ver}, GEOS {geos_ver}")
    print(f"PROJ data dir: {get_data_dir()}")

if __name__ == "__main__":
    verify_versions()

You can also spot-check from the shell without writing Python:

# pyproj bundles a CLI that prints its PROJ + data-dir details.
micromamba run -n spatial-worker python -m pyproj

# rasterio exposes the linked GDAL version directly.
micromamba run -n spatial-worker \
    python -c "import rasterio; print(rasterio.__gdal_version__)"

Common Pitfalls

  • Committing environment.yml but installing from it. The loose file re-resolves on every machine, reintroducing drift. Install from conda-lock.yml only, and treat the loose file as input.
  • Mixing defaults and conda-forge. The two channels build GDAL against different native versions; a resolver that pulls libgdal from one and rasterio from the other yields import-time crashes. Pin conda-forge first with nodefaults.
  • Forgetting a deploy platform. Locking only linux-64 leaves macOS developers on an unpinned resolve. List every platform you actually run so parity holds, which is exactly the skew that Environment Parity for Spatial Pipelines guards against.
  • Letting the lock go stale. If environment.yml changes but the lock is not regenerated, CI and prod diverge. The verify-lock job above fails the build when they disagree, feeding the delivery flow in Deployment & CI/CD for Spatial Workers.
  • Hand-editing the lockfile. It is tempting to bump a single pin directly in conda-lock.yml to dodge a re-resolve, but that breaks the internal consistency the resolver guaranteed — the edited package’s dependencies no longer match. Always change the loose input and regenerate; treat the lock as read-only output.
  • Installing dev tools outside the lock. Adding pip install lines for extra packages after the locked environment is created reintroduces the exact unpinned resolution you were avoiding. Put every runtime dependency in environment.yml so it flows through the lock.

Related: The lockfile produced here is installed by Containerizing GDAL Workers with Docker, enforced across machines by Environment Parity for Spatial Pipelines, and gated by the CI pipeline in Deployment & CI/CD for Spatial Workers; it also underpins the reproducibility assumed by State Management in Geospatial Flows. See the conda-lock documentation for advanced multi-environment layouts.

← Back to Deployment & CI/CD for Spatial Workers