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"
Matching fixture scope to loop scope
The second-largest source of instability is a fixture whose lifetime does not match the event loop’s. An asyncpg pool created in one loop and used from another raises an error that names neither the fixture nor the loop, and the message sends most people looking at the database.
The practical instruction is therefore to declare the loop scope explicitly rather than accepting the default, and to give every loop-bound resource the same scope as the loop it belongs to. That includes anything less obvious than a connection pool: an HTTP client session, a queue, a semaphore, and any object holding a future. Each of them captures the running loop at construction, and each produces the same unhelpful error when reused across a boundary.
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.