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
Make the environment visible, check pandas changes, fix CRS errors
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.
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 themultipolygonslayer 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
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
TZin 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_versionis 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 --upgradein CI. It makes every run a different environment, which is the problem you are trying to solve.
Internal links
- How to test a GIS pipeline with pytest — writing the suite this page debugs
- What to test in a GIS pipeline — which assertions survive an environment change
- How to run a Python GIS pipeline in CI with GitHub Actions — the workflow these fixes go into
- How to containerise a Python GIS pipeline with Docker — the strongest answer to drift
- How to make a GIS workflow reproducible in Python — pinning as a general practice
- ModuleNotFoundError in a scheduled GIS job — the same class of problem under cron
- Fiona vs pyogrio: how GeoPandas reads and writes files — which engine is actually doing your I/O
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.