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

O(logn)O(\log n)

for a well-balanced tree over nn geometries, versus the O(n)O(n) of a brute-force scan — so the asymptotics are similar. The practical differences are elsewhere: the R-tree has no round-trip and no persistence but must be rebuilt each run and held in RAM, while GiST is persistent, planner-optimized, and shared with production but requires a database and returns rows in planner-determined order.

Comparison reference

Axis R-tree (in-memory) GiST (PostGIS)
Location Python process PostgreSQL
Build cost Rebuilt per run, O(nlogn)O(n\log n) Persisted, built once
Query cost O(logn)O(\log n) avg, no round-trip O(logn)O(\log n) avg + query overhead
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

Failure modes and edge cases

  1. Asserting on unsorted join order. Both indexes return implementation-ordered results; sort explicitly or the gate flakes.
  2. Rebuilding the R-tree in a loop. Constructing an STRtree per query instead of once turns an O(logn)O(\log n) lookup into repeated O(nlogn)O(n\log n) builds.
  3. Testing R-tree when production is GiST. An in-memory join can pass while the production query returns different cardinality at boundary-touching geometries.
  4. No GiST index in the test database. Forgetting CREATE INDEX ... USING GIST makes the planner do a sequential scan, so the test measures nothing like production performance.
  5. 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.

Conclusion

R-tree and GiST share O(logn)O(\log n) average query cost, so the choice is about placement and fidelity: an in-memory R-tree for fast joins against fixtures that fit in RAM, a GiST index when the join mirrors a production query or the data is too large to load. Whichever you pick, impose an explicit result order before asserting, because neither guarantees a stable one. For the full tool-selection framework, return to choosing spatial testing tools.