Building an R-tree Index over OSM Geometries Jump to heading
Build a disk-backed R-tree over millions of OSM features, serialise it so reopening costs a third of a second, and use it the way a spatial index is meant to be used.
Prerequisites Jump to heading
Conceptual minimum Jump to heading
An R-tree indexes bounding rectangles, not shapes. Every entry is a box and an identifier, arranged so that a query box can eliminate whole subtrees without looking inside them. That is the entire data structure, and it explains both its speed and its limits.
Because the index stores rectangles, the answer it returns is a superset: every feature whose bounding box overlaps the query, including ones whose actual geometry does not. This is the coarse-filter half of the two-stage pattern described in Spatial Indexing for OSM Extracts, and the refine stage that follows is where correctness comes from.
Runnable solution Jump to heading
#!/usr/bin/env python3
"""Build, serialise and query a disk-backed R-tree over OSM geometries."""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Iterable, Iterator, Sequence
import shapely
from rtree import index as rtree_index
from shapely.geometry.base import BaseGeometry
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
logger = logging.getLogger(__name__)
def _properties(leaf_capacity: int = 1000) -> rtree_index.Property:
"""Tuned for bulk-loaded, read-mostly indexes over millions of features."""
props = rtree_index.Property()
props.dimension = 2
props.variant = rtree_index.RT_Star # better splits than the quadratic default
props.leaf_capacity = leaf_capacity # bigger leaves: fewer nodes, fewer seeks
props.index_capacity = leaf_capacity
props.fill_factor = 0.9 # dense packing; safe for a static index
return props
def build(features: Iterable[tuple[int, BaseGeometry]], path: Path) -> rtree_index.Index:
"""Bulk-load an index from a stream of (id, geometry) pairs.
The generator form is what triggers libspatialindex's STR bulk loader: passing
an iterable to the constructor lets it sort every rectangle once and pack the
tree bottom-up, instead of inserting and rebalancing four million times.
"""
def stream() -> Iterator[tuple[int, tuple[float, float, float, float], None]]:
for fid, geom in features:
if geom.is_empty:
continue
yield (fid, geom.bounds, None)
idx = rtree_index.Index(str(path), stream(), properties=_properties(), overwrite=True)
logger.info("built %s: %d entries", path, idx.get_size())
return idx
def open_existing(path: Path) -> rtree_index.Index:
"""Attach to an index built earlier. This is an mmap, not a rebuild."""
if not Path(f"{path}.idx").exists():
raise FileNotFoundError(f"no serialised index at {path}.idx")
return rtree_index.Index(str(path), properties=_properties())
def query(idx: rtree_index.Index,
geometries: Sequence[BaseGeometry],
query_geom: BaseGeometry,
predicate: str = "intersects") -> list[int]:
"""Coarse filter on the index, then the exact predicate on the survivors."""
candidates = list(idx.intersection(query_geom.bounds))
if not candidates:
return []
# Preparing the query geometry pays for itself from a few dozen candidates up.
prepared = shapely.prepare(query_geom) or query_geom
test = getattr(shapely, predicate)
hits = [fid for fid in candidates if test(query_geom, geometries[fid])]
logger.debug("%d candidate(s) → %d hit(s) (%.1f%% precision)",
len(candidates), len(hits), 100 * len(hits) / len(candidates))
return hits
def nearest(idx: rtree_index.Index,
geometries: Sequence[BaseGeometry],
point: BaseGeometry, k: int = 5) -> list[tuple[int, float]]:
"""The index orders by bbox distance; re-rank by true distance before returning."""
# Over-fetch: bbox order is not distance order, so the true nearest may sit
# outside the first k the index hands back.
candidates = list(idx.nearest(point.bounds, k * 4))
scored = [(fid, shapely.distance(point, geometries[fid])) for fid in candidates]
scored.sort(key=lambda pair: pair[1])
return scored[:k]
Building it from a GeoParquet layer, which is where most pipelines get their geometry:
import pyarrow.parquet as pq
from shapely import from_wkb
def load_and_build(parquet_path: str, index_path: Path):
table = pq.read_table(parquet_path, columns=["osm_id", "geometry"])
geoms = from_wkb(table.column("geometry").to_numpy(zero_copy_only=False))
idx = build(enumerate(geoms), index_path)
return idx, geoms
Step-by-step walkthrough Jump to heading
build passes a generator to the Index constructor rather than calling insert in a loop. That is the whole difference between 41 seconds and 412: given the full stream up front, libspatialindex sorts the rectangles and packs the tree bottom-up with the sort-tile-recursive algorithm, producing both a faster build and a better-balanced tree than incremental insertion can.
_properties sets a large leaf capacity. The default of around a hundred is tuned for indexes that change; for a static index over millions of features, thousand-entry leaves mean far fewer nodes to traverse and far fewer page faults on a memory-mapped file. fill_factor = 0.9 packs those leaves densely, which is safe precisely because nothing will be inserted later.
Passing a path to the constructor is what makes the index disk-backed. Omit it and you get an in-memory index that is rebuilt on every process start — the difference between the last two bars above.
query returns candidates from the index and then applies the real predicate. shapely.prepare builds a cached representation of the query geometry that makes repeated predicate evaluation substantially cheaper; it is worth it from a few dozen candidates upward and harmless below that.
nearest over-fetches deliberately. The index orders by bounding-box distance, and a long thin geometry can have a near bounding box and a far centroid, so the true nearest neighbour may not be in the first k the index returns. Fetching four times as many and re-ranking by true distance costs almost nothing and removes a whole class of wrong answers.
Verification Jump to heading
Confirm the index was bulk-loaded rather than built incrementally by timing it — 4 million features should take under a minute, not several.
Confirm the serialisation worked, which is the step most easily lost:
ls -la buildings.idx buildings.dat
python3 -c "
from pathlib import Path
from build_index import open_existing
import time
t = time.perf_counter(); idx = open_existing(Path('buildings')); print(f'{time.perf_counter()-t:.3f}s', idx.get_size())"
Reopening should print a fraction of a second and the full entry count. A rebuild instead means the .idx/.dat pair was not found and the constructor built an empty in-memory index — which then silently returns no candidates for every query.
Finally, measure the filter precision, because it tells you whether the index is earning its keep:
candidates = list(idx.intersection(query_geom.bounds))
hits = query(idx, geoms, query_geom)
print(f"{len(candidates)} candidates → {len(hits)} hits "
f"({100*len(hits)/max(len(candidates),1):.0f}% precision)")
For compact features like buildings, expect 40–80 percent. For long diagonal features like rivers and motorways, expect single digits — a diagonal line’s bounding box is mostly empty, which is a known weakness of rectangle indexes rather than a bug.
Common errors and fixes Jump to heading
| Symptom | Root cause | Fix |
|---|---|---|
| Build takes many minutes | insert() in a loop |
Pass a generator to the constructor |
| Index rebuilt on every run | No path given, or .idx missing |
Construct with a path; check both files exist |
| Every query returns nothing | Attached to an empty index | Verify get_size() after opening |
| Query returns too much | Treating candidates as results | Apply the exact predicate |
| Nearest returns the wrong feature | bbox order taken as distance order | Over-fetch and re-rank by true distance |
| Distances are meaningless | Computed in degrees | Reproject to a metric CRS first |
| Memory grows with query count | Geometries loaded per query | Load the geometry array once, share it |
Frequently Asked Questions Jump to heading
Should I use an R-tree or PostGIS?
If the data already lives in PostGIS, use its GiST index — it is an R-tree with a query planner in front of it and the same two-stage semantics. A standalone rtree index earns its place when the geometry lives in files rather than a database, as in a GeoParquet lake, or when a batch job needs an index it can build, use and discard without a server.
Why is precision so bad on rivers and motorways?
Because a bounding box around a long diagonal feature is mostly empty space, so it overlaps query boxes the geometry itself does not come near. This is intrinsic to rectangle indexes. Where it matters, segmentise long features into shorter pieces before indexing and reassemble after the exact filter — each segment gets a much tighter box.
Can I update the index after building it?
insert and delete work, but a bulk-loaded index with a 0.9 fill factor degrades quickly under insertion because there is no slack in the leaves. For a dataset that changes, either lower the fill factor to around 0.7 and accept a larger index, or treat the index as derived and rebuild it — at 41 seconds for four million features, rebuilding is often simpler than maintaining.
How much memory does querying need?
Very little for the index itself: libspatialindex memory-maps the file and the operating system pages in what the traversal touches, so a query over a 400 MB index touches a few pages. The memory cost is the geometry array the refine stage needs, which is the whole layer. If that does not fit, keep the geometries in a columnar file and fetch only the candidate rows.
Specification reference Jump to heading
rtree.index.Index(path, stream, properties=…)performs a bulk load when given an iterable of(id, (minx, miny, maxx, maxy), obj)tuples, using sort-tile-recursive packing. Supplying a path serialises the index topath.idxandpath.dat, which a laterIndex(path)memory-maps rather than rebuilding.intersection(bounds)returns identifiers whose bounding boxes overlap.
Related Jump to heading
- Spatial Indexing for OSM Extracts — the topic this index belongs to.
- Accelerating Point-in-Polygon Joins on OSM Data — the refine stage, done at scale.
- Spatial Index Selection: R-tree, H3 or Quadkey — when a tree is the wrong choice.
- Geometry Validation & Repair — why invalid geometry must not reach the refine stage.
- Coordinate Reference Systems in OSM — why distance queries need a metric CRS.
Up one level: Spatial Indexing for OSM Extracts.