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.
Core Algorithmic Pipeline
- Pin everything in a lockfile that resolves to exact versions, including the transitive C libraries.
- Key the cache on the hash of that lockfile plus the runner image identifier and the Python version.
- Cache the PROJ data package under its own key, derived from its declared version.
- Restore, then assert. Compare the resolved versions in the restored environment against the lockfile and fail the job on any mismatch.
- Record the resolved versions in the job output so a later investigation can see what actually validated the data.
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-keysallowed 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.
Related
- CI/CD Validation Pipelines for Spatial Repositories β the parent guide and the gate this speeds up
- Topology Validation Gates in GitHub Actions β the check that depends on the restored GEOS version being correct
- Provenance and Lineage Tracking for Spatial Pipelines β why the resolved versions belong in a permanent record, not only a job log
- Detecting Silent Reprojection Errors in Pull Requests β the defect an unpinned PROJ data package produces