Benchmarking Spatial Joins with pytest-benchmark
A spatial join is the operation whose cost most often surprises a team, because it is the one whose complexity depends on something invisible: whether the index was used. This guide sits beneath performance benchmarking spatial suites and shows how to build a join benchmark with pytest-benchmark and GeoPandas 0.14+ that measures the right things — candidate counts as well as timings, several input sizes rather than one, and a growth exponent that fails when the join quietly becomes quadratic.
The measurement that matters is not how long the join took. It is how many candidate pairs the filter stage produced, because that number is exact, machine-independent, and it is the quantity that changes when an index stops being used.
Root cause: the timing and the complexity are different questions
A join that takes 40 ms today and 46 ms next month has got 15 per cent slower, which is a nuisance. A join that has gone from filtering candidates through an index to comparing every pair has changed complexity, and at fixture scale those two look identical.
The reason they are indistinguishable is arithmetic. At a thousand features the difference between
Parameter reference
| Setting | Value that discriminates | Why |
|---|---|---|
| Fixture | Fixed seed, recorded config hash | A changed fixture is a changed benchmark |
| Warm-up | Discarded explicitly | A cold import dominates the first round |
| Rounds | Enough for a stable median | The median is far more robust than the mean |
| Reported statistic | Median and interquartile range | The mean follows the tail |
| Companion metric | Candidate pair count | Machine-independent; the real signal |
| Sizes | Four or five over an order of magnitude | One size cannot show a slope |
| Worker count | Fixed, usually one | Concurrency is a variable, not a constant |
Step-by-step implementation
The benchmark targets pytest-benchmark, GeoPandas 0.14+ and Shapely 2.x.
Step 1 — Build the fixtures once, at several sizes
The sizes are the whole point, and they must come from one generator so that only the count varies.
import pytest, geopandas as gpd
from shapely import STRtree
SIZES = [1_000, 4_000, 16_000, 64_000]
@pytest.fixture(scope="session", params=SIZES, ids=[f"n{n}" for n in SIZES])
def joined_pair(request):
n = request.param
left = build_points(n=n, seed=20260811) # seeded — identical every run
right = build_zones(n=max(64, n // 100), seed=20260811)
return n, left, right
Step 2 — Separate the index build from the query
Benchmarking both together measures construction, which is a different question and usually the larger number.
def test_join_query_only(benchmark, joined_pair):
n, left, right = joined_pair
tree = STRtree(right.geometry.values) # built outside the measurement
def query():
return [tree.query(g, predicate="within") for g in left.geometry.values]
result = benchmark(query)
assert sum(len(r) for r in result) > 0
Step 3 — Record the candidate count alongside the timing
This is the measurement that discriminates, and pytest-benchmark carries arbitrary extra info per benchmark.
def test_join_candidates(benchmark, joined_pair):
n, left, right = joined_pair
tree = STRtree(right.geometry.values)
candidates = 0
def query():
nonlocal candidates
candidates = sum(len(tree.query(g)) for g in left.geometry.values)
return candidates
benchmark(query)
benchmark.extra_info["n"] = n
benchmark.extra_info["candidates"] = candidates
benchmark.extra_info["candidates_per_feature"] = candidates / n
candidates_per_feature is the number to watch. For a planar dataset with a working index it is roughly constant as
Step 4 — Assert the exponent, not the duration
Fit the slope across the sizes and fail when it exceeds the bound. This is the one performance assertion worth gating on, because it is machine-independent.
import math
def test_join_complexity_is_subquadratic(benchmark_results):
"""benchmark_results: [(n, candidates), ...] collected across the sizes."""
xs = [math.log(n) for n, _ in benchmark_results]
ys = [math.log(c) for _, c in benchmark_results]
mx, my = sum(xs) / len(xs), sum(ys) / len(ys)
slope = sum((x - mx) * (y - my) for x, y in zip(xs, ys)) / sum((x - mx) ** 2 for x in xs)
assert slope <= 1.4, (
f"candidate count grows as n^{slope:.2f} — the index is not filtering; "
f"expected <= 1.4"
)
Verify the fix
Break the index deliberately and confirm the exponent assertion fires:
pytest -q tests/bench/ --benchmark-only --benchmark-columns=median,iqr
Replace the tree query with a brute-force comparison and the slope assertion should fail with the measured exponent in its message. If instead only the timing changes and the slope assertion passes, the sizes span too narrow a range to fit anything.
Reading the output without being misled
Three habits keep a benchmark report honest, and all three are about which statistic is being read.
Read the median, not the mean. A single slow round caused by unrelated work on the runner moves the mean substantially and the median barely at all. Most harnesses report both; the median is the one that describes typical behaviour.
Read the interquartile range before the central value. A wide spread means the measurement is not stable enough for the central value to mean anything, and the right response is to fix the measurement rather than to record the number. On a shared runner a spread wider than the regression you are trying to detect is normal, which is exactly why the candidate count matters more than the duration.
Compare like against like. A comparison across different fixture sizes, different worker counts or different runner classes is measuring the difference in those rather than in the code. Record all three alongside every result, and refuse to compare when they differ.
There is a fourth habit worth adopting for spatial work specifically: check the result, not only the timing. A join that got dramatically faster has often started returning fewer rows — a predicate that no longer matches, a filter applied earlier than intended — and a benchmark that asserts only on duration reports that as an improvement. Asserting the result count alongside the timing turns a suspicious speed-up into a failure.
Benchmarking the database side
When the join runs in PostGIS rather than in memory, the same principles apply and the instruments change. The candidate count becomes the number of rows the planner examined, which the database reports directly, and the exponent is fitted over that instead of over an in-process counter.
Two database-specific measurements are worth adding. The plan shape is exact and machine-independent: capturing it and asserting that the spatial index still appears is a stronger check than any timing, because a plan change is the regression rather than a symptom of one. And the buffer statistics distinguish a query that is slow because it read a great deal from one that is slow because it computed a great deal, which points at completely different fixes.
The practical arrangement is a benchmark that runs the query with statistics collection enabled, records the plan alongside the rows examined, and asserts on both the exponent and the presence of the expected index scan. Timing is recorded and watched rather than asserted, exactly as it is in process.
Failure modes and edge cases
- Benchmarking construction and query together. Index construction usually dominates, so the measurement moves when construction changes and not when the query does. Build outside the measured callable.
- A fixture regenerated per run. Different data is a different benchmark. Seed it, record the seed and the config hash, and treat a change in either as a break in the series.
- Too few sizes, or too narrow a span. A slope fitted over three sizes within a factor of two is noise. Four or five over an order of magnitude is the minimum that says anything.
- Comparing medians across worker counts. Parallelism changes cache behaviour and memory pressure. Fix the worker count for the benchmark lane.
- A speed-up that is actually a correctness regression. Assert the row count alongside the timing, or a broken predicate reads as an optimisation.
- Gating the merge on a duration. It fails on a busy runner and teaches everyone to re-run. Gate on the exponent, alert on the duration.
Conclusion
A spatial join benchmark that measures only elapsed time cannot distinguish a modest slowdown from a complexity change, and the second is the one that matters. Fixing the fixture with a recorded seed, separating construction from query, recording candidates per feature alongside the timing, and asserting the fitted exponent gives a benchmark that catches a lost index at fixture scale — which is the whole purpose of performance benchmarking spatial suites.
Related
- Performance Benchmarking Spatial Suites — the parent layer and the quantities worth measuring.
- R-tree vs GiST Index Performance in Test Environments — the two-phase filter this benchmark measures.
- Setting Runtime Budgets for Spatial Test Suites — turning the measurement into a lane budget.
- Profiling GEOS Predicate Hot Paths — finding the cost once the exponent assertion has fired.