How DuckDB Uses (and Does Not Use) a Spatial Index
Problem statement
The spatial join is slow, so you do what you would do in PostGIS: create a spatial index.
create index areas_rtree on areas using rtree (geom);
The index builds in a hundredth of a second. The join runs at exactly the same speed. Checking the plan explains why:
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ SPATIAL_JOIN โ
โ Join Type: INNER โ
โ ST_Intersects(geom, geom) โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
There is no index scan anywhere in it. DuckDB's R-tree index is real, useful and does something quite specific โ and joining two tables is not that thing.
Quick answer
The R-tree accelerates a filter against a fixed geometry. It does not participate in a table-to-table join, which uses a dedicated operator instead.
-- the index helps here: one constant geometry, many rows scanned
select count(*) from areas
where st_intersects(geom, st_makeenvelope(-74.05, 40.65, -73.85, 40.9));
-- the index does not participate here: two tables, a spatial join operator
select count(*) from points p join areas a on st_intersects(p.geom, a.geom);
con.execute("explain " + query).fetchall() # look for RTREE_INDEX_SCAN vs SPATIAL_JOIN
Measured on 4,596 province polygons: the bounding-box query dropped from 9 ms to under 1 ms with the index; the join was unchanged.
Step-by-step solution
1. Know what an R-tree does
An R-tree stores each geometry's bounding box in a balanced tree. A query with a fixed search box descends the tree, discards whole subtrees whose boxes cannot intersect, and returns a small candidate set for exact testing.
That is a one-to-many operation: one query geometry, many stored ones. It is the shape of "which features are in this view?" and "which polygon contains this point?", and it is what PostGIS uses for almost everything.
2. Know what DuckDB does for a join instead
A join is many-to-many. Probing an R-tree once per row of the other table works, and it is not the fastest strategy when both sides are large: each probe pays tree traversal, and there is no opportunity to process values in vectors.
DuckDB's SPATIAL_JOIN operator builds a spatial structure over one side and streams the other through it in batches, which suits its vectorised execution model. Measured: 13,464,017 points against 4,596 polygons in 61.8 s using 305 MB, without any index existing.
3. Create the index when the query has a fixed geometry
create index areas_rtree on areas using rtree (geom);
Building it on 4,596 polygons took 0.01 s. The queries it helps:
- bounding-box filters โ a map viewport, a study area
ST_Intersects(geom, <constant>),ST_Within(geom, <constant>)- repeated point-in-polygon lookups against the same table
The queries it does not help:
- table-to-table joins
ST_DWithinbetween two tables- anything without a fixed geometry in the predicate
4. Read the plan rather than guessing
plan = con.execute("explain " + sql).fetchall()[0][1]
print(plan)
print("uses the index:", "RTREE_INDEX_SCAN" in plan.upper())
print("spatial join: ", "SPATIAL_JOIN" in plan.upper())
This takes seconds and settles the question for the specific query in front of you, which is more reliable than any general rule โ including this article's.
5. Understand what an index costs
An R-tree is persisted with the table in a DuckDB database file, and it must be maintained on insert. For a static analytical table that is irrelevant; for a table you rewrite each run it is pure overhead.
It also does nothing at all when the table is a file being read in place. read_parquet('x.parquet') has no index and cannot have one โ which is fine, because Parquet has its own pruning mechanism.
6. Use row-group pruning instead when reading files
For file-backed data, the equivalent of an index is sorting. Parquet stores per-row-group minimum and maximum values, so a filter on a sorted column skips whole groups.
Measured over HTTP with every byte counted, filtering the same 13.5-million-row dataset by one value:
sorted file shuffled file
bytes read 0.26 MB 10.25 MB
requests 4 222
time 0.01 s 2.28 s
Same rows, same query, 39ร the bytes and 200ร the time. For files, sorting is the index.
Code examples
Example 1 โ measuring whether the index is used
import duckdb
import time
def index_benefit(con, table, geom_column="geom", box=None, repeats=5):
"""Time a bounding-box query with and without an index, and read the plan."""
box = box or "st_makeenvelope(-74.05, 40.65, -73.85, 40.9)"
query = f"select count(*) from {table} where st_intersects({geom_column}, {box})"
def best_time():
best = None
for _ in range(repeats):
start = time.perf_counter()
con.execute(query).fetchone()
elapsed = time.perf_counter() - start
best = elapsed if best is None else min(best, elapsed)
return best
without = best_time()
con.execute(f"create index if not exists {table}_rtree on {table} "
f"using rtree ({geom_column})")
with_index = best_time()
plan = con.execute("explain " + query).fetchall()[0][1]
print(f"without index {without * 1000:7.2f} ms")
print(f"with index {with_index * 1000:7.2f} ms "
f"({without / max(with_index, 1e-9):.1f}ร)")
print(f"plan uses RTREE_INDEX_SCAN: {'RTREE' in plan.upper()}")
return without, with_index
Example 2 โ the two query shapes, side by side
def compare_shapes(con, points_table, areas_table):
"""One query the index helps, one it cannot."""
filter_sql = f"""
select count(*) from {areas_table}
where st_intersects(geom, st_makeenvelope(-74.05, 40.65, -73.85, 40.9))
"""
join_sql = f"""
select count(*) from {points_table} p
join {areas_table} a on st_intersects(p.geom, a.geom)
"""
for label, sql in (("fixed-geometry filter", filter_sql), ("table join", join_sql)):
plan = con.execute("explain " + sql).fetchall()[0][1].upper()
operator = ("RTREE_INDEX_SCAN" if "RTREE" in plan
else "SPATIAL_JOIN" if "SPATIAL_JOIN" in plan
else "SEQ_SCAN + FILTER")
print(f"{label:24} โ {operator}")
fixed-geometry filter โ RTREE_INDEX_SCAN
table join โ SPATIAL_JOIN
Example 3 โ sorting a Parquet file as the file-backed equivalent
def write_clustered(con, source, target, cluster_on, compression="zstd"):
"""Row-group pruning is the index you get for a file."""
import os
con.execute(f"""
copy (select * from {source} order by {cluster_on})
to '{target}' (format parquet, compression {compression})
""")
import pyarrow.parquet as pq
meta = pq.read_metadata(target)
print(f"{meta.num_rows:,} rows in {meta.num_row_groups} row groups, "
f"{os.path.getsize(target) / 1e6:,.1f} MB")
print(f"filters on {cluster_on} can now skip groups whose min/max exclude the value")
Sorting also shrinks the file, because clustered values compress better: measured, the same data was 446 MB sorted and 741 MB shuffled โ 66% larger for the same rows.
Explanation
Why a join does not use an index
Consider probing an R-tree once per row of a 13-million-row table. Each probe descends a tree, chases pointers and returns a candidate list โ a pointer-heavy, branch-heavy operation repeated 13 million times, with no opportunity to work on values in bulk.
DuckDB's whole design is vectorised: it processes columns in batches of a couple of thousand values, keeping the CPU's pipelines full. A dedicated spatial join operator that builds a structure once and streams batches through it fits that model; per-row index probes do not.
PostGIS makes the opposite choice because it is a row-oriented system where per-row index lookups are the natural motion, and because its planner has statistics and a cost model built around indexes.
Why the index still matters
The one-to-many shape is extremely common in an application: a map server asking "what is in this tile?", a lookup asking "which region contains this point?", a repeated query against a fixed study area.
For those, the R-tree turns a full scan into a tree descent. On the small measured table the absolute numbers are tiny โ 9 ms to under 1 ms on 4,596 polygons โ but the ratio grows with the table, and a web service doing this per request cares a great deal.
Why sorting is the index for file-backed data
There is no index on read_parquet('x.parquet'), and there does not need to be one. Parquet's row-group statistics provide coarse pruning for free, and their effectiveness depends entirely on whether the values are clustered.
The measured contrast is the whole argument: the same filter read 0.26 MB in 4 requests from a sorted file, and 10.25 MB in 222 requests from a shuffled one. Sorting on write is the analytical equivalent of choosing an index โ it costs time once and pays back on every query.
Why reading the plan beats reasoning about it
Query planners change between versions. DuckDB's spatial support is developing quickly, and an operator that ignores the index today may use it in a later release.
EXPLAIN takes a second and tells you what this version does with this query on this data. It is the only claim in this article that will still be true in two years.
Edge cases or notes
create index ... using rtree (geom)requires the spatial extension loaded and a persistent database file.- Indexes need a table, not a file read in place. Create a table first if you want one.
- Index maintenance costs on insert. For a rebuilt-every-run table, skip it.
EXPLAIN ANALYZEgives timings per operator โ worth it when a query is unexpectedly slow.- Bounding-box filters benefit most; exact predicates still need a refinement step after the index.
- Sorting on write helps every file-backed query, and shrinks the file.
- Row-group size is a tuning knob for pruning granularity.
- Do not create an index to fix a slow join. Look at the plan first.
Internal links
- How to run a spatial join in DuckDB โ the operation that ignores the index
- Fixing a DuckDB spatial join that never finishes โ what to do instead
- Columnar or row storage: why DuckDB is fast on wide tables โ vectorised execution
- How to query hive-partitioned GeoParquet in DuckDB โ pruning at the file level
- PostGIS spatial indexes explained โ the row-oriented approach
- Spatial index explained โ what an R-tree is
- How to use a spatial index in GeoPandas โ the in-memory equivalent
- How to query remote GeoParquet over HTTP with DuckDB โ where the byte counts come from
FAQ
Does DuckDB have a spatial index?
Yes โ an R-tree, created with create index ... using rtree (geom). It accelerates filters against a fixed geometry and is not used by table-to-table spatial joins.
Why did creating an index not speed up my join?
Because the join uses a dedicated SPATIAL_JOIN operator rather than index probes. EXPLAIN on the query shows the operator and no index scan.
When is an R-tree index worth creating?
When many queries filter the same table by a fixed geometry โ a viewport, a study area, a point-in-polygon lookup. Measured, it took a bounding-box query from 9 ms to under 1 ms.
How do I speed up a slow spatial join then?
Filter first, simplify complex polygons, make sure both sides are in the same CRS, and set a memory limit so the engine spills rather than thrashes.
What is the equivalent of an index for a Parquet file?
Sorting. Row-group statistics let the reader skip groups: the same filter read 0.26 MB from a sorted file and 10.25 MB from a shuffled one.
How do I check whether my query uses the index?
Run EXPLAIN and look for RTREE_INDEX_SCAN in the plan. It takes a second and is more reliable than any rule of thumb.