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.

Four layers, three of them versioned independently The geospatial stack is drawn as four stacked layers with a note on what each one determines. The Python layer contains GeoPandas, Shapely, rasterio and pyproj, and pinning versions there fixes only the API surface your code calls. The binding layer contains the compiled wheels, which decide which C library build each Python package actually talks to and which can change without any Python version changing. The C library layer contains GEOS, PROJ and GDAL, and this is where geometry validity semantics, coordinate transformation pipelines and format driver behaviour genuinely live. The data layer contains the PROJ transformation grid files, which alter numerical results without any version number changing at all, because grids are downloaded rather than pinned. Python packages geopandas · shapely · rasterio · pyproj pinning fixes the API surface only compiled wheels manylinux binaries, conda builds decides which C library is loaded C libraries GEOS · PROJ · GDAL where behaviour actually changes transformation grids PROJ data, downloaded not pinned changes results with no version bump

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.

What each matrix failure pattern tells you Four patterns of partial matrix failure are listed with the conclusion each supports. When every cell fails, the defect is in the code itself and the stack version is irrelevant to it. When only the floor cell fails, the code has begun relying on a feature newer than the declared minimum supported version, so either the minimum must be raised or the code must stop using it. When only the leading-edge cell fails, an upcoming library release has changed behaviour, which makes the work scheduled rather than urgent. When only the non-Linux cell fails, the cause is almost always path handling or a wheel packaging difference rather than anything to do with geometry. Because each pattern points at a completely different investigation, fail-fast must be disabled so the whole pattern is visible. WHICH CELLS FAIL WHAT IT MEANS URGENCY all cells the defect is in the code blocks the merge floor only code uses something newer than the minimum blocks the merge leading only an upcoming release changed behaviour scheduled work non-Linux only path handling or wheel packaging, not geometry blocks the merge

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.

Declared stack, resolved stack, and the assertion between them Three stages are drawn in sequence. The workflow file declares an intended library stack for each matrix cell. The package solver resolves that declaration into concrete builds, and because a loose pin admits several builds the resolved stack can differ from the declared one. A version assertion running inside the test suite compares the resolved versions against the declared ones, so a cell that quietly received a different GDAL, PROJ or GEOS build reports a named failure rather than producing a green result that proves nothing about the version it was supposed to cover. declared gdal=3.6 proj=9.1 geos=3.11 in the workflow file resolved whatever the solver picked a loose pin admits several builds assertion in the suite compares the two, major.minor names the mismatch if any solve check Without the assertion, a cell labelled “floor” that actually installed the current stack passes and covers nothing. The failure it produces is cheap to read: declared 3.6, resolved 3.8, in the cell whose whole purpose was 3.6. Compare major and minor only — patch releases are what the solver may legitimately vary.

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.