Matrix Testing Across GDAL and PROJ Versions
A spatial test suite that passes on one GDAL build tells you the code works with that build. Every consumer of your library, every deployment target, and every developer laptop runs a different one, and the differences are not cosmetic — driver behaviour, transformation pipelines, and validity semantics all change across releases. This guide sits within GitHub Actions spatial testing and covers building a matrix that catches those differences without consuming your entire CI budget.
The trap is that the full matrix is enormous. Python versions times GDAL versions times PROJ versions times operating systems reaches the hundreds quickly, most of the combinations do not exist as installable artefacts, and a matrix that takes an hour gets disabled. The work is in choosing which axes actually vary the behaviour you care about.
Root cause: the geospatial stack has three independently versioned layers
Python packages pin a Python API; the C libraries beneath them have their own release cadence and their own compatibility rules. A geopandas version says almost nothing about which GEOS or PROJ is underneath it.
The bottom layer is the one people forget. PROJ grid files are downloaded at runtime by default, so two runs of the same pinned stack can produce different datum-shift results depending on whether the grid cache was warm — a problem covered directly in detecting missing PROJ grid files in CI.
Choosing the axes
A matrix should vary what changes behaviour and hold constant what does not.
| Axis | Vary it? | Reason |
|---|---|---|
| GDAL version | Yes | Driver behaviour, GeoPackage and FlatGeobuf semantics |
| PROJ version | Yes | Transformation pipeline selection, datum handling |
| GEOS version | Yes, coupled to GDAL | Validity, make_valid, predicate edge cases |
| Python version | Oldest and newest only | Rarely the source of spatial differences |
| Operating system | One extra, not all | Path handling and wheel differences, not geometry |
| Package manager | Both if you support both | conda-forge and PyPI ship different C builds |
Varying GEOS independently of GDAL is usually not possible with pre-built distributions and rarely worth building from source. Treating the three C libraries as one coupled axis — a stack version — collapses the matrix without losing much coverage.
Step-by-step implementation
Step 1 — Define the stack combinations explicitly
Rather than a cross-product, list the combinations that exist and matter:
name: spatial-matrix
on: [push, pull_request]
jobs:
test:
runs-on: $
strategy:
fail-fast: false
matrix:
include:
# Oldest stack still supported by consumers.
- os: ubuntu-latest
python: "3.10"
stack: "gdal=3.6 proj=9.1 geos=3.11"
label: floor
# Current conda-forge default.
- os: ubuntu-latest
python: "3.12"
stack: "gdal=3.8 proj=9.3 geos=3.12"
label: current
# Newest available, allowed to fail.
- os: ubuntu-latest
python: "3.12"
stack: "gdal=3.9 proj=9.4 geos=3.12"
label: leading
# One non-Linux target for path and wheel differences.
- os: macos-latest
python: "3.12"
stack: "gdal=3.8 proj=9.3 geos=3.12"
label: macos
fail-fast: false is essential and frequently omitted. With it left at the default, the first failing combination cancels the rest, and a partial matrix result is exactly the information you need to tell a version-specific problem from a general one.
Step 2 — Install the pinned stack reproducibly
conda-forge is the only distribution that lets you pin the C libraries directly, which is why spatial matrices generally use it rather than pip:
steps:
- uses: actions/checkout@v4
- uses: conda-incubator/setup-miniconda@v3
with:
miniforge-version: latest
python-version: $
channels: conda-forge
channel-priority: strict
- name: Install the pinned spatial stack
shell: bash -el {0}
run: |
conda install -y $ \
geopandas rasterio pyproj pytest pytest-xdist
- name: Record the versions actually installed
shell: bash -el {0}
run: |
python - <<'PY'
import pyproj, rasterio, shapely, geopandas
print("GEOS ", shapely.geos_version_string)
print("PROJ ", pyproj.proj_version_str)
print("GDAL ", rasterio.__gdal_version__)
print("gpd ", geopandas.__version__)
PY
Printing the resolved versions is not decoration. A conda solve can satisfy gdal=3.6 with several builds, and when a matrix cell fails the first question is always which libraries it actually had. Without that step the answer requires re-running the job.
Step 3 — Let the leading-edge cell fail without failing the build
A matrix that includes the newest release will break when that release changes something, and blocking every pull request on a library you have not adopted yet is how matrices get deleted:
continue-on-error: $false
The cell still runs and still reports, so the change is visible the day it lands; it just does not gate merges. Reviewing those results weekly is what turns an upgrade from an emergency into a scheduled task.
Step 4 — Assert the stack rather than assuming it
The matrix declares what should be installed; a test asserts what is. Without it, a solver that quietly resolved gdal=3.6 to something else produces a green cell that proves nothing:
import os
import re
import pytest
import pyproj
import shapely
import rasterio
def _major_minor(v: str) -> tuple[int, int]:
m = re.match(r"(\d+)\.(\d+)", v)
return (int(m[1]), int(m[2]))
@pytest.mark.skipif("EXPECTED_GDAL" not in os.environ,
reason="not running under the version matrix")
def test_installed_stack_matches_the_matrix_cell():
assert _major_minor(rasterio.__gdal_version__) == \
_major_minor(os.environ["EXPECTED_GDAL"])
assert _major_minor(pyproj.proj_version_str) == \
_major_minor(os.environ["EXPECTED_PROJ"])
assert _major_minor(shapely.geos_version_string) == \
_major_minor(os.environ["EXPECTED_GEOS"])
Comparing only major and minor is deliberate: patch releases are what conda will legitimately vary, and pinning to them makes the matrix fail for reasons that are not about your code.
Keeping the matrix affordable
Four cells at five minutes each is twenty CI-minutes per push, which is sustainable; twenty cells is not. Three techniques keep it down without losing coverage.
Run the full matrix on a schedule and a reduced one on pull requests — typically just the current cell — so ordinary development pays for one job and version drift is still caught nightly. Cache the conda environment keyed on the matrix stack string, which is stable and turns a two-minute solve into a ten-second restore; the approach mirrors caching GDAL and PROJ wheels. And mark the tests that are genuinely version-sensitive, running only those across the full matrix while the bulk of the suite runs once — a suite where 5% of tests touch transformation behaviour does not need the other 95% repeated four times.
Deciding when to raise the floor
The floor cell encodes a promise to consumers, and the question of when to move it comes up every time a new library feature would simplify some code. The decision is easier when it is made against evidence rather than preference.
The evidence that matters is what your consumers actually run. For a library published to PyPI or conda-forge, download statistics broken down by dependency version approximate it. For an internal package, the deployment targets are known exactly and the answer is simply the oldest one still in service. Either way, the floor should be a fact about the world rather than a number chosen when the matrix was first written and never revisited.
Raising it is a breaking change for anyone below the new line, so it belongs in a minor or major release with a note, not in a patch. Lowering it is almost never worth doing: supporting an older stack retroactively means testing combinations nobody asked for, and the request is usually better answered by pinning the consumer than by widening the matrix.
A useful discipline is to review the floor on a fixed cadence — once per release cycle — rather than when a feature tempts you. Reviewing it under pressure to use a new function produces a decision about that function; reviewing it on schedule produces a decision about support.
Failure modes and edge cases
Not every combination exists. conda-forge does not build every GDAL against every PROJ, and an impossible pin produces a solver error that reads like a network failure. Verify each cell resolves before committing the matrix, and keep the list short enough that verification is feasible.
fail-fast defaults to true. Left alone, it cancels the informative cells the moment one fails, which is the opposite of what a matrix is for.
Grid files change results without a version change. Pin PROJ_NETWORK=OFF and ship the grids you depend on, or two runs of the same cell can disagree.
Cache keys must include the stack. A cache keyed only on the lock file will serve one cell’s environment to another, and the version assertion above is what catches it — which is a good reason to keep that assertion even when the matrix looks correct.
macOS and Windows runners cost more minutes. GitHub bills them at a multiple of Linux, so one non-Linux cell is a deliberate budget choice, not an oversight to be corrected by adding more.
Conclusion
Treat GEOS, PROJ, and GDAL as one coupled axis and pick three or four named stacks — a supported floor, the current default, a leading edge allowed to fail, and one non-Linux cell — rather than a cross-product. Disable fail-fast so the failure pattern is visible, print and assert the versions that were actually installed, and run the full matrix on a schedule with a single cell on pull requests. That shape catches version-specific regressions while costing a few minutes per push.
Related
- GitHub Actions Spatial Testing — the parent strategy this fits into
- Caching GDAL/PROJ Wheels in GitHub Actions — making each matrix cell cheap
- Pinning GDAL/PROJ Versions in Docker Test Images — the container equivalent of a matrix cell
- Detecting Missing PROJ Grid Files in CI — the layer beneath the version pin
- Reproducible Conda Environments for Spatial CI — locking what the matrix installs