Caching GDAL/PROJ Wheels in GitHub Actions
The fast pre-merge tier of a spatial gate only stays fast if installing the geometry stack is fast, and GDAL, PROJ and their bindings are among the heaviest wheels in the Python ecosystem. This guide sits beneath GitHub Actions spatial testing and shows how to cache those wheels so a sub-minute gate is not dominated by a cold install, while keeping the cache deterministic enough that it never masks a dependency change. The tension is real: a cache that is too sticky serves stale binaries and undermines the version pinning the containerized GIS test runtimes work depends on, while no cache at all can add 30–60 seconds to every push.
Why the install dominates a fast gate
An uncached pip install geopandas on a runner resolves and downloads Shapely, pyproj, fiona and pyogrio — each carrying bundled or linked GEOS, PROJ and GDAL binaries — then compiles nothing but still writes tens of megabytes. On a warm public runner the download and unpack routinely take longer than the spatial assertions themselves. Because the pre-merge tier’s whole value is feedback in under a minute, the install is the single biggest lever, and caching the resolved wheels turns a repeated 40-second cost into a few seconds of restore.
Cache key reference
| Key ingredient | Why it belongs in the key | Effect if omitted |
|---|---|---|
Lockfile hash (requirements.lock) |
Cache must invalidate when a dependency changes | Stale wheels served after an upgrade |
| Python version | Wheels are ABI-specific per minor version | Wrong-ABI wheel restored, import errors |
| Runner OS | manylinux vs macOS wheels differ | Platform-mismatched cache hit |
| Cache “generation” salt | Manual bust for a poisoned cache | No way to force a clean rebuild |
Step-by-step implementation
The pattern below targets actions/cache@v4 and a hash-pinned lockfile, producing a cache that hits when dependencies are unchanged and rebuilds cleanly when they change.
Step 1 — Produce a hash-pinned lockfile
Resolve dependencies once and commit a lockfile with hashes, so every install is byte-identical and the cache key has something stable to hash.
pip install pip-tools
pip-compile --generate-hashes -o requirements.lock pyproject.toml
Step 2 — Key the cache on the lockfile
- name: Cache spatial wheels
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: pip-${{ runner.os }}-py3.12-${{ hashFiles('requirements.lock') }}-g1
restore-keys: |
pip-${{ runner.os }}-py3.12-
The trailing -g1 is a manual generation salt: bump it to -g2 to force a clean rebuild if a cache is ever poisoned. The restore-keys fallback lets an unchanged-OS run reuse most of a previous cache even when the lockfile changed, so an upgrade downloads only the deltas.
Step 3 — Install with hashes, from cache
- name: Install spatial stack
run: pip install --require-hashes -r requirements.lock
--require-hashes ties the install to the exact artifacts the lockfile pins, so a cache hit cannot smuggle in a different wheel than the one you resolved.
Step 4 — Prebuild a wheelhouse for private or source builds
If you build GDAL/PROJ bindings from source (for an exact version not on PyPI), build them once into a wheelhouse artifact and cache that directory, so downstream jobs install from local wheels instead of recompiling.
- name: Restore wheelhouse
uses: actions/cache@v4
with:
path: wheelhouse
key: wheelhouse-${{ runner.os }}-gdal3.9.2-proj9.4.1
- run: pip install --no-index --find-links=wheelhouse geopandas
Verification pattern
Confirm the cache both hits and stays correct: check the Actions log for a Cache restored line, and assert the installed engine versions match what the lockfile intends, so a stale cache surfaces immediately rather than as a mysterious tolerance failure downstream.
python -c "import shapely, pyproj; print(shapely.geos_version, pyproj.proj_version_str)"
# Expect the exact GEOS/PROJ the lockfile pins, e.g. (3, 12, 1) 9.4.1
Failure modes and edge cases
- Cache key without the lockfile hash. Keying on the branch or a static string serves the same wheels forever; a dependency bump is silently ignored until the salt changes.
- Cross-OS restore-key bleed. A
restore-keysprefix that omitsrunner.oscan restore Linux wheels onto a macOS job; keep the OS in both the key and the fallback. - Python minor-version mismatch. Upgrading the runner from 3.11 to 3.12 without the version in the key restores wheels built for the wrong ABI, producing import-time failures.
- Poisoned cache after a bad build. A wheelhouse cached from a broken source build sticks until you bump the generation salt — always keep a manual bust mechanism.
pipcache vs installed site-packages. Caching~/.cache/pipavoids re-download but still re-installs; for the very fastest gate, prefer a container image that ships the stack pre-installed, per the containerized runtimes approach.
Conclusion
Caching GDAL/PROJ wheels keeps the pre-merge tier fast without sacrificing determinism: key the cache on a hash-pinned lockfile plus the OS and Python version, install with --require-hashes, keep a manual generation salt for poisoning, and verify the restored engine versions on every run. Done this way, the cache saves seconds on every push and never serves a wheel your lockfile did not intend. For the surrounding workflow, return to GitHub Actions spatial testing.