Parallelizing Spatial Tests with pytest-xdist
Spatial suites are slow because geometry operations are CPU-bound, and the cheapest way to reclaim wall-clock is to run them across cores with pytest-xdist. This guide sits beneath async execution for large datasets and shows how to parallelize a spatial suite without introducing the flakiness that naïve parallelism causes: worker-safe fixtures, partitioning a large geometry collection so each worker validates a disjoint slice, avoiding double-threading between xdist and GEOS, and keeping results deterministic when the order tests complete is no longer fixed. The distinction from async execution is that xdist parallelizes across processes for CPU-bound geometry work, where asyncio parallelizes I/O within one process — they solve different halves of the “large dataset” problem.
Why naïve parallelism makes spatial tests flaky
Three things break when you add -n auto to a spatial suite that was written serially. Shared mutable fixtures — a single database, a temp file, a cached GeoDataFrame — become race conditions when multiple workers touch them at once. Result ordering stops being stable, so any assertion that relied on the incidental order of a spatial join now fails intermittently. And CPU oversubscription appears when xdist launches one worker per core while GEOS or a NumPy backend also spawns threads, so the machine thrashes and the suite gets slower, not faster. Fixing all three is what makes parallelism a speedup rather than a new source of flakes.
Configuration reference
| Concern | Serial default | xdist-safe form |
|---|---|---|
| Worker count | 1 | -n auto (or pinned -n 4) |
| Shared DB | one schema | per-worker schema via worker_id |
| Temp files | fixed path | tmp_path_factory per worker |
| Geometry partition | whole set | slice by worker_id hash |
| GEOS threads | implicit | OMP_NUM_THREADS=1 to avoid oversubscription |
| Result order | insertion | explicit sort before assert |
Step-by-step implementation
The pattern targets pytest 7+, pytest-xdist 3+, GeoPandas 0.14+, and keeps every worker isolated.
Step 1 — Isolate shared resources by worker
The worker_id fixture that xdist injects is the key to isolation: give each worker its own database schema and temp directory so nothing is shared.
# conftest.py
import pytest
@pytest.fixture(scope="session")
def db_schema(worker_id):
# worker_id is "master" when serial, "gw0"/"gw1"/... under xdist
return f"test_{worker_id}"
@pytest.fixture
def work_dir(tmp_path_factory, worker_id):
return tmp_path_factory.mktemp(f"geo_{worker_id}")
Step 2 — Partition the geometry collection
For a large validation over one collection, slice it deterministically by worker so each row is validated exactly once and no work is duplicated.
import os, geopandas as gpd
def worker_slice(gdf: gpd.GeoDataFrame, worker_id: str) -> gpd.GeoDataFrame:
n = int(os.environ.get("PYTEST_XDIST_WORKER_COUNT", "1"))
if worker_id == "master" or n <= 1:
return gdf
k = int(worker_id.removeprefix("gw"))
return gdf.iloc[k::n] # every n-th row, offset by worker index
Step 3 — Prevent thread oversubscription
Pin the geometry engine to one thread per worker so xdist’s process parallelism and GEOS/NumPy thread parallelism do not multiply.
OMP_NUM_THREADS=1 pytest -n auto -m "not slow"
Step 4 — Keep assertions order-independent
Because tests now finish in nondeterministic order, any assertion over a joined or grouped result must impose an explicit sort first — the same determinism rule the spatial assertion types work insists on.
def test_join_is_deterministic(points, zones):
joined = gpd.sjoin(points, zones, predicate="within").sort_index()
assert list(joined["index_right"]) == expected
Verification pattern
Confirm the speedup is real and the suite is still deterministic by running it serially and in parallel and comparing outcomes — same pass count, lower wall-clock.
pytest -q -m "not slow" # serial baseline
OMP_NUM_THREADS=1 pytest -q -n auto -m "not slow" # parallel: same result, faster
If the parallel run reports different failures than the serial run, an unisolated fixture or an order-dependent assertion is leaking — fix it before trusting the parallel gate.
Failure modes and edge cases
- Shared database schema. Two workers writing the same schema corrupt each other’s fixtures; derive the schema name from
worker_id. - Thread oversubscription.
-n autoplus multi-threaded GEOS launches cores² threads and slows down; setOMP_NUM_THREADS=1. - Order-dependent assertions. A join whose row order was incidentally stable serially flakes under xdist; sort explicitly before asserting.
- Uneven partitions. Slicing a collection where geometry complexity is clustered (all large polygons in one slice) unbalances workers; interleave with a stride rather than contiguous chunks.
- Session fixtures rebuilt per worker. A
scope="session"fixture runs once per worker, so an expensive build multiplies; cache it to disk keyed by content hash, or accept the per-worker cost.
Conclusion
pytest-xdist turns a slow, CPU-bound spatial suite into a parallel one, but only after isolating shared resources by worker_id, partitioning the geometry deterministically, pinning engine threads to avoid oversubscription, and making every assertion order-independent. Done that way, the suite runs several times faster with identical results. For the complementary I/O-parallel approach, return to async execution for large datasets.