Caching GDAL and GeoPandas Environments in CI Runners

Installing a spatial toolchain from scratch takes two to four minutes on a typical runner, which on a busy repository is the largest single cost in the validation pipeline β€” and the obvious fix introduces a failure mode worse than the cost. This page is a focused companion to CI/CD validation pipelines for spatial repositories.

Concept & Context

The spatial stack is unusually expensive to install because it is mostly compiled: GDAL, GEOS and PROJ are C and C++ libraries with their own dependency trees, and the Python bindings on top of them are large wheels. Every pull request paying that cost is minutes of runner time, repeated per job in a matrix.

Caching is the standard remedy and it is safe only if the cache key is exact. The classic mistake is keying on a branch name or a manually bumped version string, which produces a cache that survives a dependency change. The result is not a slow pipeline but a wrong one: geometry validated against a GEOS version nobody is using, with a green check to prove it.

There is a second, spatial-specific subtlety. The PROJ data package β€” the grid-shift files that make datum transformations accurate β€” is versioned separately from PROJ itself, is hundreds of megabytes, and directly changes coordinate output. Pinning the library and letting the grids float produces the exact silent shift that detecting silent reprojection errors in pull requests exists to catch, originating inside your own CI.

Where the time goes on a cold runner Horizontal bars of setup time on a runner with no cache: compiled spatial libraries dominate, the Python bindings follow, the PROJ grid package is substantial, and the repository checkout is negligible by comparison. COLD SETUP TIME BY COMPONENT GDAL + GEOS + PROJ 96 s Python bindings 54 s PROJ grid package 38 s cached separately β€” it moves on its own schedule Checkout 6 s Three of these four are worth caching; the fourth is why caching the whole workspace is a false economy.

Core Algorithmic Pipeline

  1. Pin everything in a lockfile that resolves to exact versions, including the transitive C libraries.
  2. Key the cache on the hash of that lockfile plus the runner image identifier and the Python version.
  3. Cache the PROJ data package under its own key, derived from its declared version.
  4. Restore, then assert. Compare the resolved versions in the restored environment against the lockfile and fail the job on any mismatch.
  5. Record the resolved versions in the job output so a later investigation can see what actually validated the data.
Everything the cache key has to include Three bands: the inputs band holds what determines the installed environment β€” lockfile hash, runner image and interpreter version; the key band holds the composed cache key; and the outcome band shows the two possible results, an exact hit or a full install. WHAT DETERMINES THE ENVIRONMENT lockfile hash runner image python version COMPOSED KEY spatial-linux-x64-py3.11-<hash> OUTCOME exact hit full install composed into resolves to There is deliberately no partial-match fallback: a nearly-right environment is the failure this design exists to prevent.

Working Implementation

# .github/workflows/spatial-validate.yml
name: Spatial validation
on:
  pull_request:
    paths: ["**/*.gpkg", "**/*.geojson", "**/*.parquet", "scripts/**"]

env:
  PROJ_DATA_VERSION: "1.19"

jobs:
  validate:
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - uses: actions/setup-python@v5
        with:
          python-version: "3.11"

      # The environment cache. The key carries everything that determines what
      # ends up installed: the lockfile, the runner image, and the interpreter.
      - name: Restore the spatial toolchain
        id: toolchain
        uses: actions/cache@v4
        with:
          path: |
            ~/.cache/pip
            ${{ env.pythonLocation }}
          key: >-
            spatial-${{ runner.os }}-${{ runner.arch }}-py3.11-${{ hashFiles('requirements.lock') }}
          # No restore-keys: a partial match would restore a DIFFERENT toolchain
          # and the job would validate against libraries the lockfile never asked for.

      - name: Install (cache miss only)
        if: steps.toolchain.outputs.cache-hit != 'true'
        run: |
          python -m pip install --upgrade pip
          pip install --require-hashes -r requirements.lock

      # PROJ grids change coordinates, are large, and move on their own schedule.
      - name: Restore PROJ data
        uses: actions/cache@v4
        with:
          path: ~/.local/share/proj
          key: proj-data-${{ env.PROJ_DATA_VERSION }}

      - name: Fetch PROJ grids if absent
        run: |
          mkdir -p ~/.local/share/proj
          if [ -z "$(ls -A ~/.local/share/proj)" ]; then
            pip install --no-deps "pyproj==3.6.1"
            python -m pyproj sync --source-id us_nga --verbose
          fi
          echo "PROJ_DATA=$HOME/.local/share/proj" >> "$GITHUB_ENV"

      # The assertion that makes the cache safe rather than merely fast.
      - name: Verify the restored toolchain
        run: python scripts/assert_toolchain.py requirements.lock
# scripts/assert_toolchain.py
"""Fail the job when the restored environment is not what the lockfile pins.

A cache restore is a guess that the key was specific enough. This turns that
guess into a check, so a stale cache surfaces as a failed job rather than as a
green validation run against the wrong geometry library.
"""
from __future__ import annotations

import re
import sys
from importlib.metadata import version, PackageNotFoundError


def locked_versions(lockfile: str) -> dict[str, str]:
    pinned = {}
    for line in open(lockfile, encoding="utf-8"):
        m = re.match(r"^([A-Za-z0-9_.\-]+)==([^\s;\\]+)", line.strip())
        if m:
            pinned[m.group(1).lower().replace("_", "-")] = m.group(2)
    return pinned


def resolved_native() -> dict[str, str]:
    from osgeo import gdal
    import pyproj
    import shapely
    return {
        "gdal-native": gdal.__version__,
        "proj-native": pyproj.proj_version_str,
        "geos-native": shapely.geos_version_string.split("-")[0].strip(),
        "proj-data": pyproj.datadir.get_data_dir().rsplit("/", 1)[-1],
    }


def main(lockfile: str) -> int:
    problems = []
    for name, expected in locked_versions(lockfile).items():
        try:
            actual = version(name)
        except PackageNotFoundError:
            problems.append(f"{name}: pinned {expected}, not installed")
            continue
        if actual != expected:
            problems.append(f"{name}: pinned {expected}, restored {actual}")

    native = resolved_native()
    print("resolved native toolchain:")
    for key, value in native.items():
        print(f"  {key:12s} {value}")

    if problems:
        print("\nrestored environment does not match the lockfile:", file=sys.stderr)
        for p in problems:
            print(f"  {p}", file=sys.stderr)
        print("\nThe cache key is not specific enough β€” a stale environment was "
              "restored. Fix the key rather than deleting the cache.", file=sys.stderr)
        return 1

    print("\ntoolchain matches the lockfile")
    return 0


if __name__ == "__main__":
    raise SystemExit(main(sys.argv[1]))

Omitting restore-keys is deliberate and worth defending in review: a partial-match restore is precisely the mechanism that installs a nearly-right environment. For ordinary application dependencies that is a reasonable trade; for the library that decides whether a geometry is valid, it is not.

Validation & Output Verification

# The cache key must change when any pinned version changes
before=$(sha256sum requirements.lock | cut -d' ' -f1)
sed -i 's/^shapely==.*/shapely==2.0.5/' requirements.lock
after=$(sha256sum requirements.lock | cut -d' ' -f1)
[ "$before" != "$after" ] && echo "key changes with the lockfile"
git checkout requirements.lock

# The environment assertion must actually fail on a mismatch
pip install --no-deps "shapely==2.0.3" >/dev/null
python scripts/assert_toolchain.py requirements.lock \
  && echo "FAIL: assertion did not catch a downgrade" \
  || echo "assertion catches a mismatched environment"
pip install --no-deps -r requirements.lock >/dev/null

Then confirm the cache is actually earning its place, since a cache that never hits is complexity for nothing:

# Cache hit rate over recent runs of the workflow
gh run list --workflow spatial-validate.yml --limit 40 --json databaseId \
  | jq -r '.[].databaseId' \
  | while read id; do
      gh run view "$id" --log 2>/dev/null | grep -c "Cache restored from key" || true
    done | awk '{h+=$1; n++} END {printf "cache hit on %d of %d runs\n", h, n}'

Failure Modes

  • A green run against the wrong libraries β€” symptom: validation passes, and the resolved versions in the log are not the pinned ones. Root cause: restore-keys allowed a partial-match restore. Fix: remove them and rely on the exact key; the assertion above catches any that slip through.

  • Coordinates shift with no code change β€” symptom: a reprojection test starts failing. Root cause: the PROJ data package updated while PROJ itself stayed pinned. Fix: cache and pin the grid version separately, and record it in the job output.

  • The cache never hits β€” symptom: every run installs from scratch. Root cause: a key including something that changes every run, such as a commit hash or a timestamp. Fix: key on the lockfile hash and the runner image only.

  • The cache is enormous and slow to restore β€” symptom: restoring takes nearly as long as installing. Root cause: caching the whole workspace rather than the package directories. Fix: cache the pip cache and the interpreter’s site-packages; leave the checkout alone.

Whether a restored cache may be trusted A decision ladder run after every cache restore: if any pinned package version differs, fail; if the native library versions differ from the record, fail; if the PROJ data package moved, fail with the coordinate implication named. Only an exact match proceeds. Does any pinned package differ from the lockfile? Fail the job the key was not specific enough yes no Do the native library versions differ? Fail the job GEOS decides what is valid geometry yes no Did the PROJ data package move? Fail and say why coordinates will differ from the last run yes no Proceed, and print the resolved versions so the log records what validated the data Without this ladder, a stale cache turns an install-time failure into a silent correctness failure.

Back to CI/CD Validation Pipelines for Spatial Repositories