GIS Tests Pass Locally but Fail in CI: How to Fix It

On this page

Problem statement

The suite is green on your laptop. You push, and CI reports failures in tests that have not been touched for weeks:

FAILED tests/test_io.py::test_names_are_text
    assert dtype('str') == dtype('O')

FAILED tests/test_io.py::test_reproject_to_bng
    rasterio.errors.CRSError: The EPSG code is unknown. PROJ: internal_proj_create_from_database: Cannot find proj.db

FAILED tests/test_transform.py::test_oakham_to_lonlat
    assert 52.6718945 == approx(52.6719137, abs=1e-06)

Each of those was reproduced for this page, and each has the same cause: the machine running the tests is not the machine you wrote them on. The first is pandas 3 on the CI runner and pandas 2 on the laptop. The second is a PROJ_DATA variable pointing somewhere without PROJ's database. The third is a coordinate transformation that used a grid file on one machine and not on the other — a 2.24 m difference from a single setting.

The tests are not flaky. They are correctly reporting that your code depends on something you never pinned. And the usual suspect is often innocent: measured across GEOS 3.11.1, 3.11.4 and 3.13.1, a buffer's area and the make_valid repairs of 19 real invalid polygons came out byte-for-byte identical.

Quick answer

Print the whole stack in CI before anything else runs — including pandas and the PROJ network setting, not only the geometry libraries — then pin it:

# .github/workflows/ci.yml
- name: Report the stack
  run: |
    python -c "
    import geopandas, pandas, pyogrio, pyproj, shapely
    print('geopandas', geopandas.__version__, '| pandas', pandas.__version__)
    print('shapely  ', shapely.__version__, '/ GEOS', shapely.geos_version_string)
    print('pyproj   ', pyproj.__version__, '/ PROJ', pyproj.proj_version_str,
          '/ network', pyproj.network.is_network_enabled())
    print('pyogrio  ', pyogrio.__version__, '/ GDAL', pyogrio.__gdal_version_string__)
    print('PROJ data', pyproj.datadir.get_data_dir())
    "

Compare that block with the same command locally. The line that differs is your bug. Then fix it at the level it belongs to:

Symptom Cause Fix
dtype('str') == dtype('O') fails; None became nan pandas 3 on one machine, 2 on the other pin pandas; test with pd.api.types.is_string_dtype
CRSError: … Cannot find proj.db PROJ_DATA points at the wrong place (rasterio, GDAL tools) unset it, or point it at a real PROJ install
transformed coordinates differ by metres PROJ grids available on one machine only pin PROJ_NETWORK and the grids, or test with a tolerance
file reads fail for one format the GDAL in use lacks the driver check pyogrio.list_drivers()
passes alone, fails in the suite shared tmp_path or CWD make fixtures independent

Where the two environments diverge

The four differences between a laptop and a CI runner behind the failures on this page. same code, same geometry engine, different resultslayeryour laptopthe CI runnershapely / GEOS2.1.2 / 3.13.12.1.2 / 3.13.1pandas2.2.33.0.6PROJ_DATAunsetstale pathOSTN15 gridfetchednot availableGDAL driverssystem GDALwheel: no ParquetGEOS 3.11 to 3.13 gave identical buffers and repairs hereThe failing tests are not wrong — they found a real difference spatialworkflow.io
The differences behind this page's failures — none of them in the geometry engine.

Make the environment visible, check pandas changes, fix CRS errors

Each CI failure symptom paired with the layer of the stack responsible. the symptom names the layerSymptom in CIWhere to fix itdtype('str') == dtype('O') failspandas 3 — pin it; useis_string_dtypeCRSError: Cannot find proj.dba stale PROJ_DATA — unset orcorrect itcoordinates 2.24 m apartPROJ grid on one machine — pin itor tolerateone file format will not opencheck pyogrio.list_drivers() in CIpasses alone, fails in the suitetmp_path and function-scopedfixturesOnly the last row is a bug in your tests spatialworkflow.io
Read the symptom, identify the layer, fix it there — not in the test.

1. Make the environment visible before you debug it

You cannot diagnose drift you cannot see. The same report, run here:

import geopandas
import pandas
import pyogrio
import pyproj
import shapely

print("geopandas", geopandas.__version__, "| pandas", pandas.__version__)
print("shapely  ", shapely.__version__, "/ GEOS", shapely.geos_version_string)
print("pyproj   ", pyproj.__version__, "/ PROJ", pyproj.proj_version_str, "/ network", pyproj.network.is_network_enabled())
print("pyogrio  ", pyogrio.__version__, "/ GDAL", pyogrio.__gdal_version_string__)
drivers = pyogrio.list_drivers()
print(len(drivers), "vector drivers;", {d: drivers.get(d) for d in ["GPKG", "Parquet", "PostgreSQL"]})
geopandas 1.1.4 | pandas 3.0.6
shapely   2.1.2 / GEOS 3.13.1
pyproj    3.8.0 / PROJ 9.8.1 / network False
pyogrio   0.13.0 / GDAL 3.12.4
64 vector drivers; {'GPKG': 'raw', 'Parquet': None, 'PostgreSQL': None}

Add it as the first step of every CI job and keep it there permanently — it costs two seconds and turns the next occurrence into a thirty-second diagnosis. The driver list matters more than people expect: the GDAL inside the pyogrio wheels has no Parquet or PostgreSQL driver, so code that works on a machine with a full system GDAL fails on one that has only the wheels.

2. Check what changed in pandas

pandas 3 made text its own dtype. A test written on pandas 2 that checks for object, or for None in a text column, passes locally and fails on a runner that installed pandas 3:

import subprocess
import sys

probe = """
import pandas as pd, geopandas as gpd
names = gpd.read_file('sites.gpkg')['name']
missing = pd.Series(['a', None], dtype=names.dtype)[1]
print(f"pandas {pd.__version__}: dtype {names.dtype!s:6} == object: {names.dtype == object!s:5} "
      f"is_string_dtype: {pd.api.types.is_string_dtype(names)!s:5} missing value: {missing!r}")
"""
for pandas_pin in ["pandas==2.2.3", "pandas==3.0.6"]:
    run = subprocess.run(["uv", "run", "-q", "--no-project", "--python", "3.12", "--with", pandas_pin,
                          "--with", "geopandas==1.1.1", "--with", "pyogrio", "python", "-c", probe],
                         capture_output=True, text=True)
    print(run.stdout.strip())
pandas 2.2.3: dtype object == object: True  is_string_dtype: True  missing value: None
pandas 3.0.6: dtype str    == object: False is_string_dtype: True  missing value: nan

Test with pd.api.types.is_string_dtype() and .isna() rather than == object and is None, and pin pandas' major version until you have run the suite on the next one.

3. Fix CRSError by fixing where PROJ looks

EPSG:27700 is not built into the libraries — it is looked up in proj.db, a file that ships with each PROJ installation. The pyproj wheel carries its own copy and uses it; rasterio and the GDAL tools follow PROJ_DATA. A stale PROJ_DATA left over from an old install, or set in a CI job for a different tool, breaks them and not pyproj:

Path("empty_proj_dir").mkdir(exist_ok=True)
probe = """
import pyproj, rasterio.crs
print('pyproj  :', pyproj.CRS.from_epsg(27700).to_epsg())
try:
    print('rasterio:', rasterio.crs.CRS.from_epsg(27700).to_epsg())
except Exception as err:
    print('rasterio:', type(err).__name__, str(err)[:90])
"""
run = subprocess.run([sys.executable, "-c", probe], capture_output=True, text=True,
                     env=dict(os.environ, PROJ_DATA=str(Path("empty_proj_dir").resolve())))
print(run.stdout.strip())
pyproj  : 27700
rasterio: CRSError The EPSG code is unknown. PROJ: internal_proj_create_from_database: Cannot find proj.db

So a test suite that only uses pyproj can pass on a machine where anything that goes through GDAL cannot find a single CRS. The fix is at the environment level: unset PROJ_DATA, or point it at a real PROJ installation; do not mix a conda PROJ with pip wheels in one environment.

4. Fix transformation drift by pinning the grids — or by testing with a tolerance

A transformation between datums can use a grid file for accuracy, and whether it does depends on the machine: on whether the grid is installed, and on whether PROJ may download it (PROJ_NETWORK). The same point in British National Grid, transformed to longitude and latitude with and without network access to the OSTN15 grid:

from pyproj import Geod, Transformer
from pyproj.network import set_network_enabled

oakham = (486000, 309000)                                   # British National Grid, metres
results = {}
for network in (False, True):
    set_network_enabled(network)
    results[network] = Transformer.from_crs(27700, 4326, always_xy=True).transform(*oakham)
    print(f"network {network!s:5}: lon {results[network][0]:.7f}  lat {results[network][1]:.7f}")
set_network_enabled(False)
print(f"difference on the ground: {Geod(ellps='WGS84').inv(*results[False], *results[True])[2]:.2f} m")
network False: lon -0.7295956  lat 52.6718945
network True : lon -0.7295852  lat 52.6719137
difference on the ground: 2.24 m

Two metres is far outside an abs=1e-6 degree tolerance and far inside anything that matters for most maps. Decide which you mean. If you need the grid, install it on every machine (or set PROJ_NETWORK=ON everywhere) and assert on the grid being used; if you do not, test with a tolerance of a few metres.

Buffer area and make_valid results under three GEOS releases. three GEOS releases, same answersbuffer(50).areamake_valid, 19 IoW polygonsGEOS 3.11.17841.3712263648same WKBGEOS 3.11.47841.3712263648same WKBGEOS 3.13.17841.3712263648same WKBShapely 2.0.1, 2.0.6 and 2.1.2, each with its bundled GEOS spatialworkflow.io
Measured in throwaway environments: the geometry engine was the one layer that did not move.

5. Assert on outcomes, not exact geometries

Geometry results can change with GEOS — but less often than CI folklore suggests. Three Shapely releases, each bundling a different GEOS, run on the same inputs:

probe = """
import hashlib, shapely, geopandas as gpd
repaired = gpd.read_file('iow_invalid.gpkg').geometry.make_valid()
digest = hashlib.sha1(b''.join(repaired.to_wkb().tolist())).hexdigest()[:12]
print(f"GEOS {shapely.geos_version_string:7} buffer(50).area {shapely.Point(0, 0).buffer(50).area:.10f}  "
      f"make_valid WKB {digest}  area {repaired.area.sum():.4f}")
"""
for pins in (["shapely==2.0.1", "numpy<2", "geopandas==0.14.4", "pyogrio==0.7.2", "pyproj==3.6.1"],
             ["shapely==2.0.6", "geopandas==1.0.1", "pyogrio"],
             ["shapely==2.1.2", "geopandas==1.1.1", "pyogrio"]):
    extra = [arg for pin in pins for arg in ("--with", pin)]
    python = "3.11" if pins[0] == "shapely==2.0.1" else "3.12"
    run = subprocess.run(["uv", "run", "-q", "--no-project", "--python", python, *extra, "python", "-c", probe],
                         capture_output=True, text=True)
    print(run.stdout.strip())
GEOS 3.11.1  buffer(50).area 7841.3712263648  make_valid WKB debf074793a5  area 23004628.6076
GEOS 3.11.4  buffer(50).area 7841.3712263648  make_valid WKB debf074793a5  area 23004628.6076
GEOS 3.13.1  buffer(50).area 7841.3712263648  make_valid WKB debf074793a5  area 23004628.6076

The buffer's area and the repairs of the Isle of Wight's 19 invalid OpenStreetMap polygons are byte-for-byte identical across GEOS 3.11.1, 3.11.4 and 3.13.1. Assert on the outcome anyway: a test tied to an exact WKT breaks on the release that does change something, and it never tested your code in the first place:

import math

import pytest

buffered = shapely.Point(0, 0).buffer(50)
assert buffered.area == pytest.approx(math.pi * 50 ** 2, rel=1e-2)     # a circle, not a polygon's exact area

bad = gpd.read_file("iow_invalid.gpkg")
repaired = bad.geometry.make_valid(method="structure", keep_collapsed=False)
assert repaired.is_valid.all()
assert repaired.area.sum() == pytest.approx(bad.area.sum(), rel=0.01)
assert repaired.geom_type.isin(["Polygon", "MultiPolygon"]).all()
print(f"buffer area {buffered.area:.2f} vs π·50² {math.pi * 2500:.2f}; {len(repaired)} repairs valid and polygonal")
buffer area 7841.37 vs π·50² 7853.98; 19 repairs valid and polygonal

The tolerance on the buffer is 1%, not 1e-9: a 64-vertex polygon is 0.16% smaller than the circle it approximates, and rel=1e-2 still fails by many orders of magnitude if someone buffers in degrees — the bug worth testing for.

6. Fix "passes alone, fails in the suite" — that one is yours

If a test passes with pytest tests/test_clean.py::test_x and fails in a full run, the environment is innocent. Something is shared:

# the usual culprits
def test_writes_output():
    gdf.to_file("output.gpkg")            # relative path — depends on CWD
    ...

@pytest.fixture(scope="session")          # session scope + mutation = order dependence
def parcels():
    return gpd.read_file(FIXTURE)

Fixes: use tmp_path for every write, make mutable fixtures function-scoped, and confirm with the pytest-randomly plugin: pytest -p no:randomly versus pytest --randomly-seed=last.

def test_writes_output(tmp_path, parcels):
    out = tmp_path / "output.gpkg"        # unique per test, cleaned up
    parcels.to_file(out)
    assert gpd.read_file(out).crs == parcels.crs

7. Stop the drift returning: pin the whole stack

Pinning the geometry packages is not enough when pandas, PROJ data and the grids also move. Pick one strategy and apply it everywhere.

# Strategy A — a container, identical everywhere (the pip stack in a venv:
# the GDAL images ship Debian's numpy 1.x, which pip cannot replace in place)
FROM ghcr.io/osgeo/gdal:ubuntu-small-3.12.2
RUN apt-get update && apt-get install -y --no-install-recommends python3-venv \
 && python3 -m venv /opt/venv && rm -rf /var/lib/apt/lists/*
COPY requirements.lock .
RUN /opt/venv/bin/pip install --no-cache-dir -r requirements.lock
ENV PATH=/opt/venv/bin:$PATH
# Strategy B — conda-lock, resolved once, reused by every machine
- uses: conda-incubator/setup-miniconda@v3
  with:
    environment-file: conda-lock.yml
# Strategy C — pip with exact pins, hashes and binary-only wheels
geopandas==1.1.1 --hash=sha256:...
pandas==2.2.3 --hash=sha256:...
shapely==2.1.2 --hash=sha256:...
pyproj==3.7.1 --hash=sha256:...

Strategy C plus --only-binary=:all: gets most teams to reproducible builds, because the wheels bundle their own GEOS, PROJ and GDAL — and it is the only one of the three where a missing pandas pin is as visible as a missing shapely pin. It stops working the moment you need a GDAL driver the wheels omit — then it is A.

Pinning the stack and a CI matrix

The data used on this page

  • sites.gpkg — the 64 towns, villages and hamlets in Geofabrik's OpenStreetMap extract for Rutland, for the text-column test.
  • iow_invalid.gpkg — the 19 invalid polygons in the multipolygons layer of Geofabrik's OpenStreetMap extract for the Isle of Wight, in British National Grid, for the geometry tests.

The pandas and GEOS comparisons run each version in a throwaway environment created with uv.

import io
import os
import zipfile
from pathlib import Path

import geopandas as gpd
import requests

if not Path("sites.gpkg").exists():
    reply = requests.get("https://download.geofabrik.de/europe/united-kingdom/england/rutland-260101-free.shp.zip",
                         timeout=300)
    reply.raise_for_status()
    zipfile.ZipFile(io.BytesIO(reply.content)).extractall("osm")
    places = gpd.read_file("osm/gis_osm_places_free_1.shp")
    places[places["fclass"].isin(["town", "village", "hamlet"])].to_file("sites.gpkg")
if not Path("iow_invalid.gpkg").exists():
    with requests.get("https://download.geofabrik.de/europe/united-kingdom/england/isle-of-wight-260101.osm.pbf",
                      stream=True, timeout=300) as reply:
        reply.raise_for_status()
        with open("iow.osm.pbf", "wb") as out:
            for chunk in reply.iter_content(1 << 20):
                out.write(chunk)
    polygons = gpd.read_file("iow.osm.pbf", layer="multipolygons", columns=["osm_id", "osm_way_id"]).to_crs(27700)
    polygons[~polygons.is_valid].to_file("iow_invalid.gpkg")
print(len(gpd.read_file("sites.gpkg")), "settlements;", len(gpd.read_file("iow_invalid.gpkg")), "invalid polygons")
64 settlements; 19 invalid polygons

Example 1: a test that pins the stack itself

If a specific capability matters, assert it. A clear failure at the top of the run beats a confusing one in the middle.

Path("test_environment.py").write_text('''
import pandas as pd
import pyogrio
import pyproj
import pytest
import shapely

MIN_GEOS = (3, 11, 0)


def test_geos_is_recent_enough():
    assert shapely.geos_version >= MIN_GEOS, f"GEOS {shapely.geos_version} is older than {MIN_GEOS}"


def test_proj_database_is_present():
    assert pyproj.CRS.from_epsg(27700).to_epsg() == 27700


def test_pandas_major_version_is_the_one_we_test_on():
    assert pd.__version__.split(".")[0] == "3", f"suite was written for pandas 3, got {pd.__version__}"


@pytest.mark.parametrize("driver", ["GPKG", "GeoJSON", "ESRI Shapefile"])
def test_required_drivers_can_write(driver):
    assert "w" in pyogrio.list_drivers().get(driver, "")
''')
run = subprocess.run([sys.executable, "-m", "pytest", "-q", "test_environment.py"], capture_output=True, text=True)
print(run.stdout.strip().splitlines()[-1])
6 passed in 0.38s

Example 2: a CI matrix that surfaces drift instead of hiding it

jobs:
  test:
    strategy:
      fail-fast: false
      matrix:
        include:
          - { os: ubuntu-latest,  python: "3.11" }
          - { os: ubuntu-latest,  python: "3.12" }
          - { os: macos-latest,   python: "3.12" }
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: ${{ matrix.python }}
          cache: pip
      - run: pip install --only-binary=:all: -r requirements.txt
      - name: Environment
        run: python -c "import pandas,shapely,pyproj,pyogrio;print(pandas.__version__,shapely.geos_version_string,pyproj.proj_version_str,pyogrio.__gdal_version_string__)"
      - run: pytest -q

fail-fast: false is the important line. Without it, the first failing cell cancels the others and you never learn whether the problem is Python 3.12 or macOS.

Example 3: reproducing CI locally before you push

# the same image CI uses, the same lock file, your working tree
docker run --rm -it -v "$PWD":/work -w /work ghcr.io/osgeo/gdal:ubuntu-small-3.12.2 \
  bash -c "apt-get update -qq && apt-get install -y -qq python3-venv >/dev/null \
           && python3 -m venv /tmp/venv && /tmp/venv/bin/pip install -q -r requirements.lock \
           && /tmp/venv/bin/python -m pytest -q"

A couple of minutes here beats eight push-and-wait cycles.

GeoPandas wraps C libraries that drift

Python GIS packages layered over the native GEOS, PROJ and GDAL libraries. what requirements.txt does and does not pinyour pipelinepinned by gitgeopandas · rasterio · fionarequirements.txtshapely · pyproj bindingsrequirements.txtGEOS · PROJ · GDALpinned by the wheel versionsPROJ data, grids, env varspinned by nothingoperating systemthe runner imageA container pins the bottom layers too; a lock file cannot pin environment variables spatialworkflow.io
Wheels pin the native libraries with them; PROJ data, grids and environment variables stay free to move.

pip install geopandas installs a Python package that is mostly a wrapper. The work happens in C and C++ libraries: GEOS does geometry operations, PROJ does coordinate transformation, GDAL/OGR does file and format handling. Those libraries have their own versions, their own release cadence, and their own bugs.

Wheels bundle a copy of each. Conda installs them as separate packages. A system Python may use whatever the distribution ships. So two environments can have identical pip freeze output and different native libraries underneath. In practice the geometry engine turned out to be the steadiest layer — three GEOS releases gave identical buffers and repairs on this page's data — while the layers around it moved: the PROJ data a library finds, the transformation grids it may use, the GDAL drivers compiled in, and pandas, which is pure Python but changed how every text column is typed.

This is why "works on my machine" is more common in GIS than in most Python work, and why the fix is almost never in the test file. The test was right. It found a real difference between two environments that you had been assuming were the same.

The corollary is worth stating plainly: a test that fails in CI has done its job. The instinct to loosen the assertion until it passes is right when the assertion was testing a library — an exact buffer area, a coordinate to the micrometre — and wrong when it was testing your code. Steps 4 and 5 above are the first case; step 6 is the second.

Time zones, memory limits and drivers

  • Timezone affects date columns. Runners default to UTC; laptops do not. Store and assert on timezone-aware datetimes, or fix TZ in the workflow.
  • Memory limits are lower in CI than on your laptop. A test that passes locally and gets killed with exit code 137 in CI is an OOM, not a logic failure — see batch GIS jobs that run out of memory.
  • The driver list differs between GDAL builds. Check pyogrio.list_drivers(): the wheels' GDAL, for example, has no Parquet or PostgreSQL driver, and minimal system builds often lack KML or FileGDB.
  • Shapely 1.x and 2.x differ materially. shapely.geos_version is the check; the 2.x rewrite changed array behaviour, not just the API.
  • Caching pip in CI can preserve a broken resolution. When debugging drift, clear the cache once before concluding anything.
  • Do not pip install --upgrade in CI. It makes every run a different environment, which is the problem you are trying to solve.

FAQ

Why do buffer areas differ by a tiny amount between machines?

A buffer approximates a circle with straight segments. The default number of segments per quarter circle is a GEOS implementation detail and has changed between releases. Use a relative tolerance of 1e-3 or looser.

My CI says CRSError: Invalid projection: EPSG:27700 but the code works locally. Why?

PROJ cannot find proj.db. Print pyproj.datadir.get_data_dir() in CI — it will point somewhere empty or wrong. Install proj-data, set PROJ_DATA, or install pyproj from wheels which bundle it.

Should I pin GEOS and PROJ versions directly?

You cannot pin them from requirements.txt — they arrive inside wheels or from the OS. Pin them by fixing the whole environment: a container image, a conda lock file, or hash-pinned binary-only wheels.

Is it acceptable to skip a test in CI?

Only for genuine environment limitations — a driver that is not available, a database that is not running — and always with pytest.mark.skipif and a reason string. Skipping to make the build green hides the drift for the next person.

Why does my test suite pass individually but fail together?

Shared state: a relative output path, a session-scoped fixture that a test mutates, or a global set by one test. This is a bug in the tests, not the environment. Use tmp_path and function-scoped fixtures.

How do I know whether to fix the code or the assertion?

Ask what the assertion is protecting against. If loosening it would still catch a metres/degrees mix-up or a dropped CRS, loosen it. If loosening it makes the test pass on incorrect output, fix the environment instead.

Does using Docker in CI solve all of this?

It solves version drift completely, because the image is the environment. It does not solve locale, timezone, memory limits or test interdependence — those still need the fixes above.