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.
What the key must contain, and what it must not
A cache is only as safe as its key, and a spatial dependency cache has more inputs than most. Every property that could change the installed bytes has to appear in the key, or a restore will serve an environment that does not match the lockfile — which is worse than no cache, because it looks like a working pin.
The manual bust counter is worth including even though it is never needed in theory. In practice a cache occasionally holds something wrong — a partially-written entry, a wheel built from a source that has since been yanked — and without a way to invalidate everything, the only remedies are editing the lockfile pointlessly or waiting for expiry. One number in the key turns that into a one-line change.
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
Caching the wheel, not the environment
There are two things a job can restore, and confusing them produces most cache-related surprises. Restoring the downloaded artefacts — the wheel files themselves — leaves the installation step to run normally, so the environment is always built the same way and only the network is skipped. Restoring the installed environment skips the installation too, which is faster and considerably more fragile: an installed tree carries absolute paths, compiled extensions bound to a specific interpreter, and anything a post-install step wrote.
There is a third option worth knowing for the awkward cases: build a wheelhouse once, as its own scheduled job, and publish it as an artefact or to an internal index. This suits private packages and anything that has to be compiled from source, because it moves the expensive build out of the gate entirely and turns every subsequent install into a fast, offline operation against known bytes. It costs a job and a little plumbing, and it is the only arrangement that makes a source-built spatial dependency compatible with a five-minute gate.
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.
Measuring whether the cache is helping
A cache is easy to configure and easy to leave misconfigured, because a broken cache looks exactly like a working one apart from the duration. Record two numbers from every run: the restore result, hit or miss, and the elapsed time of the install step. A cache with a low hit rate is usually keyed on something that changes too often; one with a high hit rate and no time saving is restoring something the install step does not use.
Both failures are common and both are invisible without the measurement. The second is the more wasteful, since it costs the restore time on every run and returns nothing — and it typically comes from caching a directory the package manager stopped using after an upgrade.
A last operational note: restore keys are a fallback, not a guarantee. A partial-match restore serves an older entry whose contents may not satisfy the current lockfile, and the install step then quietly reconciles the difference. That is usually fine and occasionally not, so treat a restore-key hit as a signal worth recording rather than as an equivalent outcome to an exact hit.
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.
Measure the hit rate and the saved seconds before deciding the cache is finished; both are cheap to record and neither is visible otherwise.
The overall aim is narrow: keep the install off the critical path without letting it become a source of ambiguity about what was installed. A cache that is keyed correctly, measured, and bustable meets that; anything looser trades a few seconds for a class of failure that is very hard to attribute.