R-tree vs GiST Index Performance in Test Environments
When a test performs a spatial join, the index behind it drives both how fast the test runs and, subtly, what order results come back in. This comparison sits beneath choosing spatial testing tools and weighs an in-memory R-tree — Shapely’s STRtree or the index behind GeoPandas sjoin — against a PostGIS GiST index, on the axes that matter for a test suite: build and query cost, determinism of result ordering, memory footprint, and whether the test must exercise the same index type production uses. Both accelerate the same operation — finding candidate geometries whose bounding boxes intersect a query box — but they live in different places, and picking the wrong one either slows the suite or lets it pass against an index that does not match production.
The root difference: in-memory structure vs database access method
An R-tree is an in-memory tree you build over a set of geometries and query directly from Python; a GiST index is a PostgreSQL access method the planner uses to answer a spatial query. Both give an average query complexity of
for a well-balanced tree over
Both indexes do the same two-phase work
The performance argument makes more sense once it is clear that neither structure answers a spatial query directly. Both do the same thing: use bounding boxes to produce a small candidate set, then run the exact geometric predicate on the survivors. The index is a filter, and everything that differs between them is about where that filter lives and what it costs to build.
Comparison reference
| Axis | R-tree (in-memory) | GiST (PostGIS) |
|---|---|---|
| Location | Python process | PostgreSQL |
| Build cost | Rebuilt per run, |
Persisted, built once |
| Query cost | ||
| Result ordering | Insertion / array order | Planner-determined |
| Memory | Whole set in RAM | Buffer cache |
| Matches production join | Only if prod is in-process | Yes, when prod uses PostGIS |
| Setup in CI | None | Needs a database service |
Where the R-tree wins
For a join against an in-memory fixture that fits in RAM, an R-tree is the fastest path and needs no database in CI. Shapely 2.x’s STRtree builds once and answers many queries, which is ideal for validating an in-process transform’s output.
# Shapely STRtree: in-memory candidate lookup, no database
from shapely import STRtree
import geopandas as gpd
zones = gpd.read_file("tests/fixtures/zones.gpkg")
points = gpd.read_file("tests/fixtures/points.gpkg")
tree = STRtree(zones.geometry.values) # build once, O(n log n)
for pt in points.geometry:
idx = tree.query(pt, predicate="within") # O(log n) average
assert len(idx) == 1, "point must fall in exactly one zone"
GeoPandas sjoin uses the same in-memory index under the hood, so a whole-frame join is one call — the right tool when the data is already in a GeoDataFrame, echoing the Shapely vs PostGIS placement logic.
Where GiST wins
When the join under test is a query you also run in production, testing against a GiST index exercises the same access method and planner behaviour, catching ordering and cardinality differences an in-memory index would hide. It is also the only option when the data is too large to hold in memory.
-- PostGIS: the same join production runs, backed by GiST
CREATE INDEX ON zones USING GIST (geom); -- built once
SELECT p.id, z.id
FROM points p JOIN zones z ON ST_Within(p.geom, z.geom);
Because the planner decides join order, a test that asserts on row order must sort explicitly — relying on the incidental order a GiST scan returns is how a suite passes locally and fails when the planner picks a different plan on the CI database.
Determinism: the trap in both
Neither index guarantees a stable result order for free. An R-tree query returns candidate indices in the tree’s internal order, and a GiST-backed query returns rows in planner order; both can change when the data or statistics change. Any assertion over a joined result must impose an explicit sort before comparing, or it becomes a flaky gate. This is the same determinism discipline the spatial assertion types work insists on — pin the order, then assert.
# Impose a deterministic order before asserting on a join result
joined = gpd.sjoin(points, zones, predicate="within").sort_values(["id_left", "id_right"])
assert list(joined["id_right"]) == expected_ids
Where the build cost lands in a test run
The performance question that matters in a test environment is not query speed — both structures answer a query in microseconds at fixture scale — it is where the index construction cost falls and how many times you pay it. That is a fixture-scope question dressed up as an index question.
An in-memory tree is built once per process and dies with it, so under pytest-xdist every worker rebuilds it. A database index is built once when the table is populated and persists for the life of the container, so all workers share it — but the container itself has to start, and the table has to be loaded. Which arrangement is cheaper depends entirely on the ratio of workers to tests.
| In-memory tree | Database index | |
|---|---|---|
| Built | Once per process | Once per table load |
| Under 8 xdist workers | Built 8 times | Built once, shared |
| Setup before first query | Milliseconds | Container start plus load |
| Survives a process restart | No | Yes, for the container’s life |
| Cost scales with | Worker count | Data volume |
The practical reading: for a small fixture and a handful of workers, the in-memory tree is obviously right and a database is overhead. For a large fixture under high parallelism the position reverses, and it reverses silently — the suite simply gets slower as the team adds workers, with no failure to investigate. Measuring the index build separately from the queries, once, is what makes that visible before it becomes a mystery.
Failure modes and edge cases
- Asserting on unsorted join order. Both indexes return implementation-ordered results; sort explicitly or the gate flakes.
- Rebuilding the R-tree in a loop. Constructing an
STRtreeper query instead of once turns anlookup into repeated builds. - Testing R-tree when production is GiST. An in-memory join can pass while the production query returns different cardinality at boundary-touching geometries.
- No GiST index in the test database. Forgetting
CREATE INDEX ... USING GISTmakes the planner do a sequential scan, so the test measures nothing like production performance. - Empty and null geometries. Both indexes skip null geometry; a join that should flag an unmatched point silently drops it — assert expected match counts, not just the matches found.
What to measure before choosing
Two numbers settle this for a given suite, and both take minutes to obtain. First, the index build time at your fixture size, measured on its own rather than inside a test — construct the tree, or create the index and load the table, and time only that. Second, the query time for a representative predicate, run enough times to be stable.
If build time dominates query time by an order of magnitude, which it usually does at fixture scale, then the index structure is nearly irrelevant to the suite’s runtime and the decision should be made on other grounds: whether the production path uses the database, whether the assertion needs set-level semantics, whether a container is already running for other reasons. Choosing an index for query performance in a test suite is optimising the smaller number, and it is the reason this comparison is more often about fixtures than about trees.
Conclusion
R-tree and GiST share
When production and test disagree about which index answers a query, the test is measuring something production never does — which is worth checking before either structure is optimised.
Record the index type the test used in the run summary, so a later performance question can be answered without re-running anything.