Article

Matrix Testing a Geospatial CLI Across GDAL Versions

To test a geospatial CLI across GDAL versions, define a GitHub Actions strategy.matrix over python-version and gdal-version, run each job inside the matching ghcr.io/osgeo/gdal container (or a conda-forge gdal=<ver> pin), install your package, run pytest, and upload coverage per job. Set fail-fast: false so one red combination does not cancel the rest. This page is part of the Packaging & CI/CD for Python GIS CLI Tools guide within the broader CLI Architecture & Design Patterns for Python GIS reference.

Prerequisites

  • Python 3.10 or later, with your CLI installable via pip install -e .
  • A test suite using pytest and pytest-cov
  • Familiarity with GitHub Actions workflow YAML
  • GDAL is provided by the container image or conda environment in CI — you do not install it with pip

The core problem is that GDAL is a C library, and its Python bindings (rasterio, pyproj, fiona/pyogrio) are compiled against a specific ABI. A test that passes against GDAL 3.4 can regress on GDAL 3.8 because a driver was renamed, a default changed, or axis-order handling shifted. A version matrix surfaces these regressions before your users do. For how these pins fit into your release story, see the parent Packaging & CI/CD for Python GIS CLI Tools guide.

How the Matrix Fans Out

Each cell in the matrix is an isolated job with its own GDAL container and Python interpreter. The diagram below shows how one workflow expands into a grid of jobs, with one combination excluded because no compatible build exists.

GDAL and Python test matrix expansion A single workflow definition on the left fans out into a grid of six jobs, three GDAL versions across the top and two Python versions down the side. Five cells are green passing jobs; one cell is marked excluded because no compatible build exists. ci.yml strategy.matrix fail-fast: false GDAL 3.4 GDAL 3.6 GDAL 3.8 Py 3.10 Py 3.12 pytest pass pytest pass pytest pass excluded no wheel pytest pass pytest pass 6 combinations minus 1 exclude = 5 isolated jobs, each with its own gdalinfo --version

Complete Working Implementation

The workflow below runs your CLI test suite against three GDAL versions and two Python versions using the official OSGeo container images. It pins rasterio and pyproj to builds compatible with the container GDAL, runs pytest with coverage, and uploads a per-job coverage artifact. Save it as .github/workflows/gdal-matrix.yml:

name: gdal-matrix

on:
  push:
    branches: [main]
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest
    # Run every job inside the exact GDAL the leg targets. The bindings
    # already present in the image match this GDAL's ABI.
    container:
      image: ghcr.io/osgeo/gdal:ubuntu-small-$NaN
    strategy:
      # Do not cancel sibling jobs when one GDAL version regresses.
      fail-fast: false
      matrix:
        python-version: ["3.10", "3.12"]
        gdal-version: ["3.4.3", "3.6.4", "3.8.5"]
        include:
          # Add one extra leg pinning the newest GDAL without expanding
          # the full cross product.
          - python-version: "3.12"
            gdal-version: "3.9.2"
        exclude:
          # GDAL 3.4 image ships no interpreter compatible with 3.12.
          - python-version: "3.12"
            gdal-version: "3.4.3"
    name: py$NaN · gdal$NaN
    steps:
      - uses: actions/checkout@v4

      - name: Report the resolved GDAL version
        run: |
          gdalinfo --version
          echo "GDAL_VERSION=$(gdal-config --version)" >> "$GITHUB_ENV"

      - name: Install test dependencies and the package
        run: |
          python3 -m pip install --upgrade pip
          # Pin bindings to the container GDAL so pip does not pull a
          # wheel built against a different ABI (GDAL_VERSION set above).
          case "$GDAL_VERSION" in
            3.8*|3.9*) RASTERIO=1.3.11 ;;
            *)         RASTERIO=1.3.9  ;;
          esac
          python3 -m pip install \
            "rasterio==${RASTERIO}" \
            "pyproj>=3.6,<3.8" \
            pytest pytest-cov
          python3 -m pip install -e . --no-build-isolation

      - name: Run the CLI test suite with coverage
        run: |
          pytest -q \
            --cov=geocli --cov-report=xml:coverage.xml \
            --junitxml=results.xml

      - name: Upload coverage for this matrix leg
        uses: actions/upload-artifact@v4
        with:
          name: coverage-py$NaN-gdal$NaN
          path: coverage.xml
          if-no-files-found: error

The reprojection assertion the matrix is meant to protect lives in an ordinary pytest file. This test proves that a point transformed from EPSG:4326 to EPSG:3857 lands where Web Mercator expects it, and it is stable across GDAL point releases because it rounds the result:

# tests/test_reproject.py
import math

import pytest
from pyproj import CRS, Transformer


def reproject_point(lon: float, lat: float) -> tuple[float, float]:
    """Reproject a lon/lat point to Web Mercator (EPSG:3857).

    always_xy=True forces longitude-first input regardless of the
    authority axis order, so this call is stable across GDAL/PROJ versions.
    """
    transformer = Transformer.from_crs(
        CRS.from_epsg(4326),
        CRS.from_epsg(3857),
        always_xy=True,
    )
    return transformer.transform(lon, lat)


def test_reprojection_to_web_mercator():
    # Null Island's neighbour: 1 degree east, on the equator.
    easting, northing = reproject_point(1.0, 0.0)

    # 1 degree of longitude at the equator in EPSG:3857.
    assert math.isclose(easting, 111319.49, abs_tol=0.5)
    # The equator maps to northing 0 in Web Mercator.
    assert math.isclose(northing, 0.0, abs_tol=1e-6)


@pytest.mark.parametrize("epsg", [3857, 32633])
def test_target_crs_is_projected(epsg):
    # Guard against a CRS regression: both targets must be projected,
    # never geographic, or downstream pixel maths breaks.
    assert CRS.from_epsg(epsg).is_projected

Step Annotations

  1. container.image interpolates matrix.gdal-version — Running the whole job inside ghcr.io/osgeo/gdal:ubuntu-small-<ver> means the GDAL C library, gdal-config, and the bundled Python bindings all match the version under test. There is no separate GDAL install step and no risk of a cached system GDAL shadowing it.

  2. fail-fast: false — By default GitHub Actions cancels every sibling job the moment one fails. For a compatibility matrix that is exactly the wrong behaviour: you want to see that GDAL 3.8 is the only red leg, not have the run aborted before GDAL 3.4 and 3.6 finish. Set it to false.

  3. include: adds a single extra leg — The include block appends one python-version: 3.12 / gdal-version: 3.9.2 job without multiplying it across every Python version. Use it to smoke-test a bleeding-edge GDAL on your newest interpreter only.

  4. exclude: drops an impossible combination — The GDAL 3.4 image predates Python 3.12, so that cell can never install. Naming the exact pair under exclude keeps the matrix green instead of carrying a permanently red job that trains reviewers to ignore failures.

  5. gdalinfo --version before anything else — Printing the resolved version at the top of the log is your proof that the container pin took effect. If a step silently fell back to a cached GDAL, this line makes it obvious instead of letting a stale binding pass the suite.

  6. Pinning rasterio and pyproj to the container GDAL — pip wheels for rasterio bundle their own GDAL, which can override the container’s. Installing a rasterio version built for the container GDAL, or building with --no-build-isolation against the system GDAL, keeps the ABI consistent. This is the single most common source of matrix flakiness.

  7. always_xy=True in the transformer — This forces longitude-first input and output regardless of the CRS authority axis order, making the reprojection deterministic across PROJ and GDAL point releases. The named gotcha below explains why this matters.

Named Gotcha: Axis Order Changes Between GDAL Versions

The single most common cross-version failure is authority-compliant axis order. Since GDAL 3 and PROJ 6, coordinate transformations honour the axis order declared by the CRS authority, so EPSG:4326 is latitude-then-longitude, not the longitude-first convention many older scripts assume. A test that hardcodes transformer.transform(lon, lat) against a Transformer built without always_xy=True can silently swap coordinates, and the exact behaviour drifts as pyproj and its bundled PROJ move between GDAL 3.4, 3.6, and 3.8. Driver defaults shift too — for example, creation-option defaults and NULL-handling in some drivers changed across these releases.

The fix is twofold. First, always build transformers with always_xy=True so your CLI’s coordinate order is explicit and version-independent, as shown in the test above. Second, pin pyproj and rasterio to versions built against the GDAL in each matrix leg, rather than letting pip resolve whatever wheel it prefers. When your CLI reads pins or driver preferences from configuration, keep them consistent across environments using the Environment Variable Sync pattern so local runs and CI resolve the same GDAL behaviour. For exercising the command entry points themselves, drive them with Testing Click Commands with CliRunner for GIS Tools.

Verification

A correctly configured matrix shows one green check per surviving combination on the pull request, and each job log names the GDAL it used. Confirm the pins locally before pushing:

# Confirm the container GDAL and the binding GDAL agree.
gdalinfo --version
python3 -c "import rasterio; print('rasterio sees GDAL', rasterio.gdal_version())"
python3 -c "import pyproj; print('PROJ', pyproj.proj_version_str)"

# Run only the reprojection guard, quietly.
pytest tests/test_reproject.py -q

If gdalinfo --version and rasterio.gdal_version() disagree, a mismatched wheel slipped in and the leg is testing the wrong GDAL. When both report the same version and pytest exits 0, the matrix leg is trustworthy. A non-zero exit from pytest (exit code 1) on a single GDAL version, with siblings green, is the exact regression signal this workflow exists to produce.

Troubleshooting

Symptom Root Cause Fix
rasterio.gdal_version() differs from gdalinfo --version pip pulled a wheel bundling its own GDAL Pin rasterio to the container GDAL or install --no-build-isolation
Reprojection off by swapped coordinates Authority axis order (lat/lon vs lon/lat) Build Transformer with always_xy=True
Whole run cancels on first red leg fail-fast defaults to true Set fail-fast: false under strategy
One matrix cell can never install GDAL image predates the Python version Add the pair to exclude:
PROJ: proj_create: cannot find proj.db PROJ_DATA unset in the container Export PROJ_DATA=/usr/share/proj before pytest

FAQ

Should I test against GDAL container images or conda-forge builds?

Use the official ghcr.io/osgeo/gdal container images when you want the exact C library and system bindings the OSGeo project ships, and they start fast in CI. Use conda-forge gdal=<ver> pins when your users install via conda or when you need a GDAL version not published as an image. Many projects run both as separate matrix legs to cover both installation paths.

Why does my reprojection test pass on GDAL 3.4 but fail on GDAL 3.8?

The most common cause is authority-compliant axis order. Since PROJ 6 and GDAL 3, transformations respect the CRS axis order, so EPSG:4326 is latitude then longitude. If your test hardcodes longitude-first inputs it can drift across GDAL and PROJ point releases. Pin pyproj and rasterio to versions built against the container GDAL and assert on rounded coordinates rather than exact floats.

How do I skip a known-incompatible GDAL and Python combination?

Add an exclude entry under strategy.matrix that names the exact python-version and gdal-version pair to drop, or use include to add a single extra leg without expanding the full cross product. This keeps the matrix green when a specific combination has no compatible wheel or container image.

How do I confirm each job actually used the GDAL version I intended?

Add a step that runs gdalinfo --version and python -c to print rasterio.gdal_version() before pytest. Printing the resolved version in the log proves the container or conda pin took effect and makes a silent fallback to a cached GDAL obvious in the job output.