Running async spatial tests with pytest-asyncio
Running async spatial tests with pytest-asyncio is the problem of evaluating many spatial relationships concurrently — querying a PostGIS pool with asyncpg, fanning out per-feature topology checks, validating raster/vector pairs in parallel — without the event loop conflicts, fixture-scope leaks, and CPU-blocking stalls that make a green test suite flaky on the next CI run. The exact tools are pytest-asyncio (≥1.0), the asyncio event loop, an async database driver such as asyncpg, and the asyncio.to_thread offload primitive that keeps synchronous GEOS/GDAL calls off the loop. This page sits beneath topology rule enforcement, the relationship-validation layer of the Spatial Test Pattern Design & Implementation discipline, and shows the precise configuration and code that turns concurrent topology validation into a deterministic, copy-button-ready gate.
Concurrency is not a performance luxury here. A coverage check over a parcel layer is an all-pairs relationship problem; running each pair’s intersects/overlaps predicate sequentially against a synchronous cursor leaves the runner idle on I/O. Async lets the database round-trips overlap while the CPU-bound geometry work is dispatched to a thread pool — but only if the loop is never blocked and every fixture is torn down on the loop that created it.
Root cause: why async spatial tests destabilise CI
Async spatial failures are rarely about the spatial logic. They come from three structural mismatches between how pytest-asyncio manages loops and how spatial libraries behave.
- Event-loop nesting and policy conflicts.
pytest-asynciocreates an isolated event loop per test function by default. Spatial libraries that wrap C extensions —geopandas,shapely,rasterio, the GDAL bindings — may spawn worker threads or hold references to a loop captured at import time. Calling back into the loop from that foreign context raisesRuntimeError: This event loop is already runningorasyncio.InvalidStateError. The failure surfaces intermittently because it depends on thread scheduling, which is exactly what makes it look like a spatial bug. - Fixture scope outliving its loop. An async fixture (
@pytest_asyncio.fixture) scoped tosessionormoduleopens anasyncpgpool on one loop, but per-function tests run on fresh loops. The pool’s connections are now bound to a dead loop, so the nextacquire()either hangs or raisesgot Future attached to a different loop. Connection exhaustion during parallel topology validation is the same bug wearing a different mask. - Blocking the loop with CPU-bound geometry.
shapelyvalidity tests,geopandas.overlay, andpyprojtransforms are synchronous and CPU-bound. Awaiting them directly inside anasync defdoes not yield — it freezes the entire loop, starving every other coroutine until the call returns. Underpytest-timeoutthis trips the deadline and masks the real spatial result behind a timeout.
The unifying fix is a strict loop boundary: pin the loop scope, dispatch every synchronous spatial call through asyncio.to_thread, and tear pools down on the loop that created them.
Configuration and offload reference
The settings below are the whole contract. asyncio_mode = "strict" forces an explicit @pytest.mark.asyncio on every coroutine test, so a fixture can never be silently awaited on the wrong loop; loop_scope keeps a session pool and its tests on one loop.
| Key / callable | Where | Value / signature | Role in the gate |
|---|---|---|---|
asyncio_mode |
pyproject.toml |
"strict" |
Require explicit @pytest.mark.asyncio; no auto-marking |
@pytest.mark.asyncio(loop_scope=...) |
test / fixture | "session" | "module" | "function" |
Bind tests and their async fixtures to one loop |
@pytest_asyncio.fixture(loop_scope=...) |
conftest.py |
matches the pool’s lifetime | Keep the pool on the loop that created it |
asyncio.to_thread(fn, *args) |
test body | Coroutine |
Offload CPU-bound Shapely/GDAL off the loop |
asyncio.gather(*tasks) |
test body | Future[list] |
Fan-out concurrent predicate evaluation |
pytest-xdist --dist |
CLI | loadfile |
One loop + one GDAL cache per worker |
@pytest.mark.timeout(n) |
test | seconds (pytest-timeout) |
Per-test deadline; pytest-asyncio has no timeout= |
The reason offloading is mandatory is a throughput argument, not a style preference. For
because nothing overlaps. Dispatching the CPU work to a pool of
so the win scales with the worker count only while the loop itself stays free. One inline shapely call collapses the second formula back into the first.
Step-by-step implementation
Step 1 — Pin strict mode and markers
Configure pytest-asyncio 1.0+ in pyproject.toml. strict mode is what prevents an async fixture from being pulled onto a per-function loop by accident.
[tool.pytest.ini_options]
asyncio_mode = "strict"
addopts = "-v --tb=short --strict-markers"
timeout = 120 # global default from pytest-timeout; per-test override with @pytest.mark.timeout
markers = [
"spatial_async: async spatial validation (deselect with '-m \"not spatial_async\"')",
]
Step 2 — Scope the database pool to its loop
Give the asyncpg pool an explicit loop_scope that matches the tests consuming it, and close it in the same fixture so connections never outlive their loop.
# conftest.py — pytest-asyncio >= 1.0, asyncpg >= 0.29
import asyncpg
import pytest_asyncio
@pytest_asyncio.fixture(loop_scope="session", scope="session")
async def async_db_pool():
"""Session pool pinned to the session loop; torn down on that same loop."""
# asyncpg takes a plain libpq DSN — not the SQLAlchemy "postgresql+asyncpg" form
pool = await asyncpg.create_pool(
dsn="postgresql://user:pass@localhost/spatial_db",
min_size=2,
max_size=10,
)
yield pool
await pool.close() # closes on the loop that opened it — no cross-loop Future
Step 3 — Offload CPU-bound geometry to a thread
Never await a Shapely predicate inline. is_valid is a property, so wrap it in a callable — to_thread needs a function, not its already-evaluated result.
import asyncio
import pytest
from shapely.geometry import shape
@pytest.mark.asyncio(loop_scope="session")
async def test_async_geometry_validation():
geojson = {"type": "Polygon",
"coordinates": [[[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]]]}
geom = shape(geojson)
# property -> wrap in lambda so a callable (not a bool) is dispatched off-loop
is_valid = await asyncio.to_thread(lambda: geom.is_valid)
assert is_valid, "Geometry failed validation"
Step 4 — Fan out concurrent topology rule checks
This is the payoff for topology rule enforcement: fetch candidates with one async round-trip, then evaluate every relationship predicate concurrently in the thread pool. The per-feature function is pure and synchronous so it is trivially poolable.
import asyncio
import pytest
from shapely.wkb import loads as wkb_loads
def _check_overlap_rule(feature_id: int, geom_wkb: bytes) -> dict:
"""CPU-bound relationship check; runs in a thread executor, never on the loop."""
geom = wkb_loads(geom_wkb)
# Real logic compares against a prepared spatial index; placeholder validity here.
return {"id": feature_id, "violates": not geom.is_valid}
@pytest.mark.asyncio(loop_scope="session")
async def test_async_topology_overlap_check(async_db_pool):
async with async_db_pool.acquire() as conn:
rows = await conn.fetch(
"SELECT id, ST_AsEWKB(geom) AS geom "
"FROM parcels WHERE status = 'pending'"
)
tasks = [
asyncio.to_thread(_check_overlap_rule, r["id"], bytes(r["geom"]))
for r in rows
]
results = await asyncio.gather(*tasks)
violations = [r for r in results if r["violates"]]
assert not violations, f"Topology violations detected: {violations}"
Step 5 — Parallelise cross-format I/O
Concurrent disk reads accelerate the kind of work covered under cross-format parity testing. Use aiofiles for the read and GDAL’s /vsimem/ virtual filesystem to hand the bytes to OGR/GDAL without touching disk twice.
import asyncio
from pathlib import Path
import aiofiles
from osgeo import gdal
gdal.UseExceptions()
@pytest.mark.asyncio(loop_scope="session")
async def test_cross_format_parity():
async def read_and_validate(path: str) -> bool:
async with aiofiles.open(path, "rb") as f:
data = await f.read()
# Multi-file formats (Shapefile .shx/.dbf) need their sidecars staged in /vsimem/ too.
vsi = f"/vsimem/{Path(path).name}"
gdal.FileFromMemBuffer(vsi, data)
try:
return gdal.OpenEx(vsi) is not None
finally:
gdal.Unlink(vsi)
results = await asyncio.gather(
read_and_validate("test.shp"),
read_and_validate("test.tif"),
)
assert all(results), "Cross-format parity check failed"
Verify the fix
The fastest confirmation that the loop is never blocked is a probe test: schedule a heartbeat coroutine alongside an offloaded CPU task and assert the heartbeat kept ticking. If the geometry work were run inline, the heartbeat count would be zero.
import asyncio
import pytest
@pytest.mark.asyncio(loop_scope="session")
async def test_loop_is_not_blocked():
ticks = 0
async def heartbeat():
nonlocal ticks
for _ in range(5):
await asyncio.sleep(0.01)
ticks += 1
def cpu_bound(): # stand-in for shapely.overlay / is_valid
return sum(i * i for i in range(2_000_00))
hb = asyncio.create_task(heartbeat())
await asyncio.to_thread(cpu_bound) # offloaded -> loop stays free
await hb
assert ticks == 5, "event loop was blocked during CPU-bound spatial work"
Run the suite as a pre-merge gate with pytest -q -m spatial_async; a non-zero exit blocks the merge. To surface hidden async defects in CI, export PYTHONASYNCIODEBUG=1 before the run — it logs unawaited coroutines and unclosed spatial file handles that otherwise pass silently.
Failure modes and edge cases
- Session pool on a per-function loop. The most common failure: a
loop_scope="function"test consuming aloop_scope="session"pool raisesgot Future attached to a different loop. Align the test’sloop_scopewith the fixture’s, or scope the pool tofunctionand accept the reconnect cost. xdistworkers sharing a spatial index. Underpytest-xdist, two workers writing the same R-tree or GDAL cache corrupt it non-deterministically. Use--dist=loadfileso a file’s tests stay on one worker, and give each worker its own cache directory viaPYTEST_XDIST_WORKER.- GDAL exceptions swallowed by
gather.asyncio.gatherwithoutreturn_exceptions=Falsewill propagate the first error, but a GDAL C-level abort can bypass Python’s exception machinery and kill the worker outright. Callgdal.UseExceptions()once at import so OGR errors become catchableRuntimeErrors. - Empty result set, vacuous pass. A topology query returning zero rows makes
gather(*[])succeed instantly and the assertion trivially true. Assertlen(rows) > 0(or mark the layer as expected-empty) so an upstream query bug cannot disguise itself as a clean gate. - CRS-unit mismatch before the async fan-out. Tolerance and
ST_DWithinradii are in CRS units; mixing a degree-based EPSG:4326 geometry with a metre-based threshold yields false passes that no amount of concurrency will catch. Validate the CRS synchronously — and derive the radius the way you would when setting up spatial tolerance thresholds in assertions — before entering the async context.
By pinning asyncio_mode = "strict", scoping the asyncpg pool to its loop, offloading every CPU-bound Shapely/GDAL call through asyncio.to_thread, and proving the loop stays free with a heartbeat probe, concurrent spatial validation becomes a deterministic, high-throughput gate rather than a source of flaky CI — the same relationship-level contract that the broader catalogue of topology rule enforcement is built on.
Related
- Topology Rule Enforcement — the parent reference for adjacency, connectivity, containment, and exclusion predicates this page runs concurrently.
- Async Execution for Large Datasets — broader strategies for chunked and streamed validation over datasets too large for one pass.
- Validating Polygon Topology with GeoPandas — the per-feature validity gate that runs before these relationship checks.
- Comparing GeoJSON vs Shapefile Outputs in Tests — the parity checks parallelised in Step 5.