How to Run a Spatial Join in DuckDB
Problem statement
A spatial join is the operation that dominates most GIS workloads: which points fall in which polygons, which parcels touch which flood zone, which roads cross which boundary.
In GeoPandas it is one line and it holds both datasets in memory. That is fine until it is not โ and the point at which it stops being fine is measurable. Joining 13,464,017 points to 4,596 polygons:
engine time peak memory result
DuckDB 61.8 s 305 MB 12,942,217 pairs
GeoPandas 117.5 s 4,739 MB 12,942,217 pairs
Same answer, 1.9ร the speed and 15.5ร less memory. The memory figure is the one that decides whether the job runs at all on an ordinary machine.
But DuckDB is not automatically the right choice: on a small join โ 7,342 points against the same 4,596 polygons โ GeoPandas was faster, 0.104 s against 0.168 s.
Quick answer
A spatial join is a JOIN with a spatial predicate:
select p.name, a.name as area
from read_parquet('places.parquet') p
join st_read('areas.shp') a
on st_intersects(st_point(p.lon, p.lat), a.geom);
DuckDB recognises the predicate and plans a dedicated SPATIAL_JOIN operator rather than a nested loop โ which is what makes 62 billion candidate pairs finish in a minute.
import duckdb
con = duckdb.connect()
con.execute("install spatial; load spatial;")
con.execute("set enable_progress_bar = false")
result = con.execute(query).df()
Step-by-step solution
1. Choose the predicate deliberately
| Predicate | True when | Typical use |
|---|---|---|
ST_Intersects |
they share any point | the default; points in polygons |
ST_Within / ST_Contains |
one is entirely inside the other | strict containment |
ST_Touches |
they share a boundary but no interior | adjacency |
ST_Crosses |
interiors cross | a road crossing a boundary |
ST_DWithin |
within a distance | "shops within 500 m" |
ST_Intersects and ST_Within differ on the boundary: a point exactly on a polygon edge intersects it and is not within it. On a coverage of adjacent polygons, ST_Intersects can therefore produce two matches for one point, which is usually not what you want in a count.
2. Build point geometry inline when the source is coordinates
Most large point datasets arrive as latitude and longitude columns, not as geometry. Constructing the point in the join is cheaper than materialising a geometry column first:
join areas a on st_intersects(st_point(p.lon, p.lat), a.geom)
ST_Point(x, y) is longitude first, matching every GIS file convention.
3. Check the coordinate systems before you believe the answer
DuckDB does not raise on a CRS mismatch. It returns zero rows:
select typeof(geom) from areas limit 1; -- GEOMETRY('EPSG:4326')
select typeof(geom) from points limit 1; -- GEOMETRY <- no CRS at all
A join across mismatched systems produces a count of zero that looks exactly like "nothing matched". Check the types, or transform one side explicitly with always_xy := true.
4. Aggregate in the query
The reason to use a database engine is that the aggregate never leaves it. Counting points per polygon returns 51 rows rather than 2.2 million:
select a.name, count(*) as points, avg(p.population) as mean_population
from read_parquet('places.parquet') p
join st_read('states.shp') a
on st_intersects(st_point(p.lon, p.lat), a.geom)
where p.country = 'US'
group by 1
order by points desc;
Measured: this exact shape ran in 4.27 s with 227 MB peak memory on 2,241,395 points against 51 polygons, against 15.5 s and 988 MB for the GeoPandas equivalent.
5. Count what did not match
An inner join silently drops unmatched rows, and the number of them is usually informative:
select count(*) filter (where a.name is null) as unmatched,
count(*) filter (where a.name is not null) as matched
from read_parquet('places.parquet') p
left join st_read('states.shp') a
on st_intersects(st_point(p.lon, p.lat), a.geom);
In the measured US join, 2,241,395 points produced 2,198,704 matches โ 42,691 points, 1.9%, fell outside every state polygon. Offshore features, territories and generalised coastlines account for most of it, and knowing the number stops it becoming a surprise in a total.
6. Watch the row count on many-to-many joins
A join is not a lookup. A point on a shared boundary matches two polygons; a road crossing three counties produces three rows. The output can be larger than either input, which is correct and is the usual reason a "join" produces more rows than expected.
If you need one row per input feature, aggregate โ count(*), string_agg, or any_value โ rather than assuming the join is one-to-one.
Code examples
Example 1 โ the join, with the checks that make it trustworthy
import duckdb
def spatial_join_summary(points_source, polygons_source, polygon_name="name",
lon="lon", lat="lat", predicate="st_intersects"):
"""Join, aggregate, and report what did not match."""
con = duckdb.connect()
con.execute("install spatial; load spatial;")
con.execute("set enable_progress_bar = false")
con.execute(f"create table areas as select * from {polygons_source}")
crs = con.execute("select typeof(geom) from areas limit 1").fetchone()[0]
print(f"polygon geometry type: {crs}")
if crs == "GEOMETRY":
print(" ! no CRS on the polygons โ a mismatch will return zero rows silently")
totals = con.execute(f"""
select count(*) as total,
count(a.{polygon_name}) as matched
from {points_source} p
left join areas a
on {predicate}(st_point(p.{lon}, p.{lat}), a.geom)
""").fetchone()
unmatched = totals[0] - totals[1]
print(f"{totals[0]:,} point-rows, {totals[1]:,} matched, "
f"{unmatched:,} unmatched ({100 * unmatched / totals[0]:.1f}%)")
return con.execute(f"""
select a.{polygon_name} as area, count(*) as n
from {points_source} p
join areas a on {predicate}(st_point(p.{lon}, p.{lat}), a.geom)
group by 1 order by n desc
""").df()
The left join count runs first deliberately. A zero-match result is far cheaper to diagnose before you have spent a minute on the aggregate.
Example 2 โ a distance join with an explicit radius
def within_distance(con, points_table, sites_table, metres, crs="EPSG:27700"):
"""Everything within `metres` of a site. Requires a projected CRS โ
a distance in degrees is not a distance."""
con.execute(f"""
create or replace table points_proj as
select * exclude geom,
st_transform(geom, 'EPSG:4326', '{crs}', always_xy := true) as geom
from {points_table}
""")
con.execute(f"""
create or replace table sites_proj as
select * exclude geom,
st_transform(geom, 'EPSG:4326', '{crs}', always_xy := true) as geom
from {sites_table}
""")
return con.execute(f"""
select s.name as site, count(*) as within_{metres}m,
round(min(st_distance(p.geom, s.geom)), 1) as nearest_m
from points_proj p
join sites_proj s on st_dwithin(p.geom, s.geom, {metres})
group by 1 order by 2 desc
""").df()
Reprojecting both sides into a projected CRS before a distance join is not optional. ST_DWithin on lon/lat measures degrees, and a degree is 111 km at the equator and 56 km at 60ยฐ north.
Example 3 โ verifying against GeoPandas on a sample
def cross_check(con, points_source, polygons_path, sample=50_000):
"""Run both engines on a sample and assert they agree."""
import geopandas as gpd
duck = con.execute(f"""
select count(*) from (select * from {points_source} limit {sample}) p
join st_read('{polygons_path}') a
on st_intersects(st_point(p.lon, p.lat), a.geom)
""").fetchone()[0]
frame = con.execute(f"select lon, lat from {points_source} limit {sample}").df()
polygons = gpd.read_file(polygons_path)
points = gpd.GeoDataFrame(frame,
geometry=gpd.points_from_xy(frame.lon, frame.lat),
crs=polygons.crs)
pandas_count = len(gpd.sjoin(points, polygons, predicate="intersects"))
print(f"duckdb {duck:,} geopandas {pandas_count:,} "
f"{'agree' if duck == pandas_count else 'DISAGREE'}")
assert duck == pandas_count, (
"the two engines disagree โ usually a CRS difference or a boundary "
"predicate difference; check before trusting either")
Run this once when you move a pipeline from GeoPandas to DuckDB. A faster answer that differs from the one you trusted is not a speed-up.
Explanation
Why DuckDB's spatial join is not an index lookup
EXPLAIN on a spatial join shows a dedicated SPATIAL_JOIN operator, not an index scan. Even with an R-tree index present on the polygon table, the join plan does not use it:
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ SPATIAL_JOIN โ
โ Join Type: INNER โ
โ ST_Intersects(geom, geom) โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
The operator builds its own spatial structure over one side and probes it with the other, which is the right strategy for joining two whole tables. The R-tree index is for filtering a table by a fixed geometry โ a bounding-box query โ and DuckDB uses it there.
Knowing this stops a common wasted afternoon: creating an index and finding the join is no faster, because it was never going to use it.
Why the memory difference is larger than the speed difference
The global join was 1.9ร faster and 15.5ร lighter. GeoPandas materialises both GeoDataFrames and the join result in Python objects; DuckDB streams the probe side through the join and emits aggregated rows.
That is why the same query completed with memory_limit set to 200 MB โ measured at 4.18 s, no slower than the unconstrained run. The engine was never holding the data in the first place.
Why GeoPandas wins on small joins
DuckDB pays fixed costs: parsing and planning the query, loading the extension, building the join structure. On 7,342 points against 4,596 polygons those costs dominate, and GeoPandas' in-memory R-tree does the whole job in 0.104 s against DuckDB's 0.168 s.
The crossover is somewhere in the low hundreds of thousands of rows, and it depends on the shapes involved. Below it, use whatever is convenient; above it, the difference stops being a matter of taste.
Why unmatched rows deserve a number
Every spatial join has an unmatched population, and it is never zero on real data. Measured: 1.9% of US-coded points fell outside the 51 state polygons, and 3.9% of global points fell outside every province.
Those points are offshore features, small islands the generalised boundaries omit, and slivers between polygons. None of that is an error, and all of it changes a total. Counting the unmatched rows converts an invisible discrepancy into a documented one.
Edge cases or notes
ST_Intersectsincludes the boundary;ST_Withindoes not. A point on a shared edge matches two polygons with the first.- A CRS mismatch returns zero rows, not an error. Check the geometry column types.
ST_DWithinmeasures in the coordinate units โ reproject before using it.- Invalid geometry can produce wrong results rather than an error;
ST_IsValidbefore a join on untrusted data. - The output can be larger than either input. Aggregate if you need one row per feature.
- An R-tree index does not speed up a table-to-table join โ it speeds up a filter against a fixed geometry.
left joinplus acount(... ) filtergives matched and unmatched in one pass.- Cross-check against GeoPandas once when porting a pipeline.
Internal links
- DuckDB spatial explained: a spatial database that is just a file โ the engine
- How DuckDB uses (and does not use) a spatial index โ why the index is not in the plan
- Fixing a DuckDB spatial join that never finishes โ when it does not complete
- CRS in DuckDB: why ST_Transform moves your data to the wrong place โ the zero-row failure
- How to join points to polygons with a spatial join โ the GeoPandas equivalent
- Spatial join cardinality explained โ why the row count changes
- How to aggregate millions of points into a grid with DuckDB โ joining to a grid instead of polygons
- How to benchmark DuckDB against GeoPandas honestly โ making the comparison fairly
FAQ
How do I do a spatial join in DuckDB?
A normal JOIN with a spatial predicate: join areas a on st_intersects(st_point(p.lon, p.lat), a.geom). DuckDB plans a dedicated spatial join operator for it.
Is DuckDB faster than a GeoPandas sjoin?
At scale, yes. 13.5 million points against 4,596 polygons took 61.8 s and 305 MB in DuckDB against 117.5 s and 4,739 MB in GeoPandas. On a 7,342-point join GeoPandas was faster.
Why does my spatial join return zero rows?
Usually a CRS mismatch, which DuckDB does not report as an error. Check the geometry column types on both sides before believing an empty result.
Do I need a spatial index for the join?
No โ the join uses a dedicated operator, not the R-tree. An index helps a bounding-box filter against a fixed geometry, not a table-to-table join.
Why does the join return more rows than my input?
Because a point on a shared boundary matches two polygons, and a line crossing several areas matches each. Aggregate if you need one row per input feature.
How do I find the points that matched nothing?
Use a left join and count the null side. In the measured US join, 42,691 of 2,241,395 points โ 1.9% โ fell outside every state polygon.