Planetary-scale answers, unlocked.
A Hands-On Guide for Working with Large-Scale Spatial Data. Learn more.
Authors
A spatial join combines two tables by where their geometries sit. It pairs rows whose shapes meet a spatial condition, with no shared ID column needed.
Examples are a point that falls inside a polygon, two parcels that share a boundary, and a facility within 2 kilometers of a river. Data about the physical world rarely shares a key across sources. So the spatial join is the step that connects building footprints to flood zones, delivery stops to sales territories, and satellite pixels to farm fields.
ST_Intersects
ST_DWithin
ST_Contains
EXPLAIN FORMATTED
An attribute join, such as Esri's Add Join tool or a SQL JOIN ... ON a.id = b.id, matches rows when a common field holds equal values. A spatial join matches rows on how their shapes relate. Esri's Spatial Join tool transfers attributes from join features to target features by their spatial relationship. In SQL the relationship goes in the ON clause as a function call.
JOIN ... ON a.id = b.id
ON
customer_id
The two often chain: a spatial join gives each building a parcel ID, and an attribute join on it brings in assessor records.
Start from a query. This one counts the points of interest inside each Colorado city and town, using open data from the Overture Maps Foundation:
WITH localities AS ( SELECT id, names.primary AS city, geometry FROM wherobots_open_data.overture_maps_foundation.divisions_division_area WHERE subtype = 'locality' AND region = 'US-CO' ) SELECT l.id, l.city, COUNT(p.id) AS places FROM localities AS l JOIN wherobots_open_data.overture_maps_foundation.places_place AS p ON ST_Intersects(l.geometry, p.geometry) GROUP BY l.id, l.city
That JOIN is spatial because of its geometry columns, a shared CRS, and a spatial predicate. Each side has a geometry or geography column. Geometry stores flat-plane coordinates. Geography stores them on the curved Earth, with distances in meters. Both sides share a CRS. The International Association of Oil & Gas Producers (IOGP) keeps a registry of coordinate systems, the EPSG Dataset. It gives each one a code, such as EPSG:4326. EPSG:4326 stores degrees, and EPSG:2263 stores New York State Plane feet. A join across two systems returns wrong rows, so ST_Transform one side first. A spatial predicate sits in the ON clause. A predicate is a test that returns true or false for a pair of shapes. Each left row can match zero, one, or many right rows, so a point on a shared city line can appear twice.
JOIN
ST_Transform
Topological predicates test how two shapes relate. Max Egenhofer and Robert Franzosa (1991) compared the interiors and boundaries of two shapes. Eliseo Clementini and colleagues (1993) added the dimension of each intersection. The result is the Dimensionally Extended 9-Intersection Model (DE-9IM). It is a 3 by 3 matrix that records where the interior, boundary, and exterior of one shape meet those of the other. The Open Geospatial Consortium (OGC) Simple Feature Access standard (ISO 19125) defines Intersects, Contains, Within, Touches, Crosses, Overlaps, Equals, and Disjoint as patterns of that matrix. Every compliant implementation returns the same result for the same pair.
Intersects
Contains
Within
Touches
Crosses
Overlaps
Equals
Disjoint
ST_Intersects(A, B)
ST_Contains(A, B)
ST_Within(B, A)
ST_Covers(A, B)
ST_Touches(A, B)
ST_Crosses(A, B)
ST_DWithin(A, B, d)
d
ST_Intersects, the broadest predicate, is symmetric: swapping A and B gives the same answer. Under the PostGIS definition, a polygon does not contain a point on its boundary, because the interiors share no point; ST_Covers matches it. Parcels that share a fence line touch, and parcels that overlap by a meter do not.
ST_Covers
An inner join keeps only matched pairs, so a city with no points of interest drops out of the query above. A left join keeps every left row, with nulls in the right table's columns and a COUNT(p.id) of 0. A full outer join keeps unmatched rows from both sides: cities with no places, and places outside every city.
COUNT(p.id)
A left join followed by WHERE p.id IS NULL is an anti join, which returns rows with no spatial match, such as buildings outside every flood zone. ST_Disjoint in a join condition matches almost every pair. In WherobotsDB, predicate, distance, and raster-vector joins accept LEFT JOIN and FULL OUTER JOIN. The ST_KNN join is an inner join on the query table: a query row with no neighbor is left out.
WHERE p.id IS NULL
ST_Disjoint
LEFT JOIN
FULL OUTER JOIN
ST_KNN
A predicate join, also called a range join, pairs rows whose geometries satisfy a topological predicate. Point in polygon is the standard case: ON ST_Contains(zones.geometry, assets.geometry) gives each address or store its zone. The exact test checks the point against every polygon edge, so its cost grows with the number of vertices.
ON ST_Contains(zones.geometry, assets.geometry)
A distance join pairs rows whose geometries lie within a distance of each other, such as ON ST_DWithin(stations.geom_m, homes.geom_m, stations.walk_radius_m). The distance can be a column, so each row carries its own radius. For geometry the distance follows the CRS: on EPSG:4326 data, ST_DWithin(a, b, 2) means 2 degrees, hundreds of kilometers. Project both sides to meters, pass useSpheroid = true (centroid to centroid), or use the geography type.
ON ST_DWithin(stations.geom_m, homes.geom_m, stations.walk_radius_m)
ST_DWithin(a, b, 2)
useSpheroid = true
A nearest-neighbor join returns, for each row on one side, the k closest rows on the other, with no radius. ST_KNN(R, S, k, use_sphere, search_radius) takes five arguments: the query geometry, the object geometry, k, the distance model, and an optional search radius. With use_sphere = false, the distance is measured on a flat plane, boundary to boundary. With true, it is great-circle meters between centroids, which shifts results for large shapes. Distances in degrees rank neighbors wrongly, because a degree of longitude shrinks toward the poles. Project to meters first. ST_AKNN returns approximate neighbors faster.
ST_KNN(R, S, k, use_sphere, search_radius)
use_sphere = false
true
ST_AKNN
A raster stores a value in every grid cell. A raster-vector join pairs raster tiles with the geometries they overlap, using RS_Intersects, RS_Contains, or RS_Within. It then summarizes the pixels inside each geometry with zonal statistics, such as their count, sum, or mean. Examples are mean elevation per town from a digital elevation model and vegetation per field from a Sentinel-2 Normalized Difference Vegetation Index (NDVI) raster.
RS_Intersects
RS_Contains
RS_Within
A spatial predicate has no equal-value key to group rows by, so a naive join compares every pair. Matching 10 million buildings against 50,000 flood zones takes 500 billion tests. A join that runs on a sample in a single-node database such as PostGIS can stall on a full country. Filtering on boxes, partitioning space, and indexing cut the cost.
The filter step compares minimum bounding rectangles, the smallest upright box around each shape, at four number comparisons per pair. It keeps the pairs whose boxes overlap. The refine step runs the exact predicate on those candidates only. It drops the false hits, pairs whose boxes overlap while their shapes do not. Brinkhoff, Kriegel, Schneider, and Seeger formalized the multi-step processing of spatial joins (SIGMOD 1994), adding cheaper approximations between the two steps so fewer candidates reach the costly geometry test.
On a cluster, space is split first so that rows that could match land on the same worker:
sedona.join.gridtype = kdbtree
A polygon that crosses a cell edge is copied to every cell it touches, and duplicate pairs are removed after the join.
Inside each partition, an index finds candidates without a full scan. Antonin Guttman's R-tree (SIGMOD 1984) nests nearby bounding boxes in larger rectangles. A lookup then searches only the branches that overlap its own box. A quadtree index does the same with square cells. WherobotsDB supports both (sedona.global.indextype), and its optimizer documentation shows the index as a SpatialIndex node in the plan.
sedona.global.indextype
SpatialIndex
A small side, such as a few hundred city polygons, can be broadcast: copied to every worker and indexed there while the large side stays in place. Two large sides are partitioned into the same KDB-tree cells and joined cell by cell. WherobotsDB names the two plans BroadcastIndexJoin and RangeJoin (or DistanceJoin for distance predicates). A broadcast applies to a side under the size threshold or with a /*+ BROADCAST(t) */ hint, where the join type allows it.
BroadcastIndexJoin
RangeJoin
DistanceJoin
/*+ BROADCAST(t) */
Grids such as H3 offer another route. Each row on both sides gets a grid cell ID, and an attribute join matches equal IDs. A refine step then runs the exact predicate, because two shapes in one cell may still not touch.
WherobotsDB, the cloud spatial engine in Wherobots for vector and raster data, comes from the original creators of Apache Sedona and is compatible with Sedona's APIs. The four queries below ran in a Wherobots SQL session, and every join result below comes from them. They read three tables in Wherobots Open Data. Each is a Havasu table, Apache Iceberg with geometry and raster column types.
overture_maps_foundation.divisions_division_area
overture_maps_foundation.places_place
copernicus_dem.glo_30m
Raster and vector joins ran in one engine, each query in 9.5 to 61.9 seconds; most stacks pick one data model. In a notebook, pass the same SQL to sedona.sql():
sedona.sql()
from sedona.spark import SedonaContext config = SedonaContext.builder().getOrCreate() sedona = SedonaContext.create(config)
Count Overture places in every Colorado locality, and keep localities with none. Window functions, which compute totals across all result rows, add the summary columns.
WITH localities AS ( SELECT id, names.primary AS city, geometry FROM wherobots_open_data.overture_maps_foundation.divisions_division_area WHERE subtype = 'locality' AND region = 'US-CO' ), places AS ( -- Prefilter places to Colorado's bounding box before the join SELECT id, geometry FROM wherobots_open_data.overture_maps_foundation.places_place WHERE bbox.xmin BETWEEN -109.06 AND -102.04 AND bbox.ymin BETWEEN 36.99 AND 41.0 ), counts AS ( SELECT l.id, l.city, COUNT(p.id) AS place_count FROM localities l LEFT JOIN places p ON ST_Intersects(l.geometry, p.geometry) GROUP BY l.id, l.city ) SELECT city, place_count, RANK() OVER (ORDER BY place_count DESC) AS place_rank, COUNT(*) OVER () AS localities, SUM(CASE WHEN place_count = 0 THEN 1 ELSE 0 END) OVER () AS localities_with_no_places, SUM(place_count) OVER () AS matched_pairs FROM counts ORDER BY place_count DESC, city
The query ran in 23.6 seconds and returned all 304 Colorado localities, with 243,542 locality and place pairs. Colorado Springs leads with 33,521 places, ahead of Aurora with 15,931 and Boulder with 12,701. Eleven localities have no places, rows an inner join would hide. Most are campgrounds tagged as localities, such as Kelly Dahl Campground, and one is the town of Delta, a boundary worth checking. The locality filter sits in the WITH clause, before the join, so it never removes the unmatched rows.
WITH
A full outer join needs both sides filtered before the join, and an ID from each side in the output, because an unmatched place has no city to group by:
-- Boulder area WITH localities AS ( SELECT id, geometry FROM wherobots_open_data.overture_maps_foundation.divisions_division_area WHERE subtype = 'locality' AND region = 'US-CO' AND bbox.xmin BETWEEN -105.35 AND -105.0 AND bbox.ymin BETWEEN 39.9 AND 40.1 ), places AS ( SELECT id, geometry FROM wherobots_open_data.overture_maps_foundation.places_place WHERE bbox.xmin BETWEEN -105.3 AND -105.2 AND bbox.ymin BETWEEN 39.98 AND 40.06 ), pairs AS ( SELECT l.id AS locality_id, p.id AS place_id FROM localities l FULL OUTER JOIN places p ON ST_Intersects(l.geometry, p.geometry) ) SELECT SUM(CASE WHEN locality_id IS NOT NULL AND place_id IS NOT NULL THEN 1 ELSE 0 END) AS matched_pairs, SUM(CASE WHEN place_id IS NULL THEN 1 ELSE 0 END) AS localities_with_no_places, SUM(CASE WHEN locality_id IS NULL THEN 1 ELSE 0 END) AS places_outside_every_locality FROM pairs
Around Boulder the query returns 11,914 matched pairs, 6 localities with no places, and 321 places outside every locality. It took 42.2 seconds, and the plan in the next section shows why.
Find the nearest hospital to every school around central Denver, and count the hospitals within 2 km of each. Both sides move to Universal Transverse Mercator (UTM) zone 13N (EPSG:26913), a CRS in meters, so distances come back in meters. The query writes that CRS as a string for PROJ, the open source projection library. The hospital box is wider, so schools near its edge still match their true nearest hospital.
-- Schools and hospitals in a box around central Denver, in UTM zone 13N meters (EPSG:26913) WITH schools AS ( SELECT id, names.primary AS school, geometry, ST_Transform(geometry, 'EPSG:4326', '+proj=utm +zone=13 +datum=NAD83 +units=m +no_defs') AS geom_m FROM wherobots_open_data.overture_maps_foundation.places_place WHERE taxonomy.primary IN ('elementary_school', 'middle_school', 'high_school') AND bbox.xmin BETWEEN -105.11 AND -104.6 AND bbox.ymin BETWEEN 39.61 AND 39.91 ), hospitals AS ( -- Search a wider area so schools near the edge still find their true nearest hospital SELECT id, names.primary AS hospital, geometry, ST_Transform(geometry, 'EPSG:4326', '+proj=utm +zone=13 +datum=NAD83 +units=m +no_defs') AS geom_m FROM wherobots_open_data.overture_maps_foundation.places_place WHERE taxonomy.primary = 'hospital' AND bbox.xmin BETWEEN -105.3 AND -104.4 AND bbox.ymin BETWEEN 39.4 AND 40.1 ), nearest AS ( -- k-nearest-neighbor join: the one closest hospital to each school SELECT s.id, s.school, h.hospital, ST_X(s.geometry) AS school_lon, ST_Y(s.geometry) AS school_lat, ST_X(h.geometry) AS hospital_lon, ST_Y(h.geometry) AS hospital_lat, ST_Distance(s.geom_m, h.geom_m) AS distance_m FROM schools s JOIN hospitals h ON ST_KNN(s.geom_m, h.geom_m, 1, false) ), within_2km AS ( -- Distance join: every hospital within 2 km of each school, keeping schools with none SELECT s.id, COUNT(h.id) AS hospitals_within_2km FROM schools s LEFT JOIN hospitals h ON ST_DWithin(s.geom_m, h.geom_m, 2000) GROUP BY s.id ) SELECT n.school, n.hospital, ROUND(n.distance_m) AS distance_m, w.hospitals_within_2km, n.school_lon, n.school_lat, n.hospital_lon, n.hospital_lat, COUNT(*) OVER () AS schools, ROUND(AVG(n.distance_m) OVER ()) AS mean_distance_m, ROUND(MAX(n.distance_m) OVER ()) AS max_distance_m, SUM(CASE WHEN w.hospitals_within_2km = 0 THEN 1 ELSE 0 END) OVER () AS schools_with_none_within_2km, SUM(CASE WHEN n.distance_m > 2000 THEN 1 ELSE 0 END) OVER () AS schools_nearest_over_2km FROM nearest n JOIN within_2km w ON n.id = w.id ORDER BY distance_m DESC
In 9.5 seconds the query paired 596 schools with a hospital, at a mean distance of 1,589 meters. Turnberry Elementary School, the farthest, sits 9,915 meters from Intermountain Health Platte Valley Hospital. The distance join found 151 schools with no hospital within 2 km. The KNN join found the same 151 schools with a nearest hospital beyond 2 km, so the two join types agree.
Compute the mean surface height of the towns in a 1-degree box over the central Colorado Rockies. The source is the Copernicus 30 m DEM, a surface model that includes roofs and trees. A town can span several tiles, so the query sums pixel values and counts per tile and divides once per town.
-- Mean surface height of the towns in a 1-degree box over the upper Arkansas Valley and South Park WITH towns AS ( SELECT id, names.primary AS town, geometry FROM wherobots_open_data.overture_maps_foundation.divisions_division_area WHERE subtype = 'locality' AND region = 'US-CO' AND bbox.xmin >= -106.6 AND bbox.xmax <= -105.6 AND bbox.ymin >= 38.8 AND bbox.ymax <= 39.8 ), dem AS ( -- Keep only the elevation tiles that touch the box SELECT rast FROM wherobots_open_data.copernicus_dem.glo_30m WHERE ST_Intersects(footprint, ST_GeomFromText( 'POLYGON((-106.6 38.8, -105.6 38.8, -105.6 39.8, -106.6 39.8, -106.6 38.8))', 4326)) ), per_tile AS ( -- One zonal-statistics call per town-tile pair returns the pixel count and sum together SELECT t.id, t.town, RS_ZonalStatsAll(d.rast, t.geometry, 1) AS z FROM towns t JOIN dem d ON RS_Intersects(d.rast, t.geometry) ) SELECT town, COUNT(*) AS tiles, SUM(z.count) AS cells, ROUND(SUM(z.sum) / SUM(z.count)) AS mean_surface_m FROM per_tile GROUP BY id, town ORDER BY mean_surface_m DESC
The join paired 22 localities with their tiles in 61.9 seconds. Alma comes out highest at a mean of 3,185 meters over 1,262 cells in 2 tiles, followed by Montezuma at 3,140 meters and Leadville at 3,088 meters.
Prefix a query with EXPLAIN FORMATTED to print its physical plan without running it. Each plan came back in under 5 seconds and names the join operator WherobotsDB will run:
INTERSECTS
RTREE
BroadcastNestedLoopJoin
BroadcastQuerySideKNNJoin
rast
None of the plans contains a CartesianProduct, and four of the five use an R-tree. The Boulder plan explains its 42 seconds. The broadcast join types are inner, left semi, left anti, left outer, and right outer. A full outer spatial join is none of these, so it runs as a nested loop. On large tables, build the same result from a left join plus a left anti join from the other side. Both run as indexed joins. A plan also shows where filters run: in the raster plan the footprint test runs after the scan of glo_30m, so every tile row is read first.
CartesianProduct
glo_30m
Jack Orenstein's Spatial query processing in an object-oriented database system (SIGMOD 1986) mapped two-dimensional space onto a one-dimensional z-order curve. A spatial join became a merge of two sorted lists of z-values followed by an exact check, an early filter-and-refine join. Thomas Brinkhoff, Hans-Peter Kriegel, and Bernhard Seeger joined two R-trees by traversing them together in Efficient processing of spatial joins using R-trees (SIGMOD 1993).
R-tree joins assume both inputs carry an index. Jignesh Patel and David DeWitt's Partition based spatial-merge join (SIGMOD 1996), PBSM, handled inputs with none. It partitions both sides on a grid and copies shapes that cross an edge. It joins each partition with a plane sweep, which scans shapes in coordinate order, and removes duplicates. Edwin Jacox and Hanan Samet's Spatial join techniques (ACM TODS, 2007) surveys the field.
Nick Roussopoulos and colleagues gave an R-tree algorithm for nearest neighbor queries (SIGMOD 1995). Christian Böhm and Florian Krebs defined the k-nearest neighbour join (2004) as an operator.
Ablimit Aji and colleagues built Hadoop-GIS (PVLDB 2013) on MapReduce. Ahmed Eldawy and Mohamed Mokbel's SpatialHadoop (ICDE 2015) added a global spatial index to Hadoop, and Eldawy, Louai Alarabi, and Mokbel compared seven partitioning schemes for joins (PVLDB 2015).
Jia Yu, Jinxuan Wu, and Mohamed Sarwat brought these ideas to Apache Spark with GeoSpark (ACM SIGSPATIAL 2015). Yu, Zongsi Zhang, and Sarwat described its partitioners and indexes in a 2019 GeoInformatica paper. GeoSpark became Apache Sedona, the lineage of the RangeJoin and BroadcastIndexJoin operators in WherobotsDB. Varun Pandey and colleagues benchmarked these systems in How good are modern spatial analytics systems? (PVLDB 2018). On SpatialBench at scale factor 1000, the current WherobotsDB runs spatial queries and joins up to 2.5 times faster than the previous generation, with a mean speedup of 1.9 times.
Samriddhi Singla, Ahmed Eldawy, and colleagues defined Raptor (SIGSPATIAL 2021), a raster-vector join operator for Spark. It reads pixels in place in one scan, so neither side of the raster versus vector split needs conversion.
RS_ZonalStatsAll
ST_Contains(asset, zone)
ST_MakeValid
ST_IsValid
Run your first spatial join with a Wherobots free trial at cloud.wherobots.com.
A spatial join combines two tables by a spatial relationship between their geometries, such as one shape containing, touching, or lying within a set distance of another. Each output row pairs a record from the left table with a record from the right table that meets the condition. A spatial join needs no shared ID column, because the match is on location alone.
A store point that falls inside a city boundary and a pipeline that crosses a river are spatial relationships. So are two parcels that share a boundary and a school within 500 meters of a highway. In SQL each one is a predicate: ST_Contains, ST_Crosses, ST_Touches, and ST_DWithin.
ST_Crosses
ST_Touches
Use a spatial join when two datasets describe the same places but share no key. Common cases are assigning points to the polygon they fall in, counting features per zone, and finding the nearest facility to each customer. Another common case is summarizing raster values such as elevation or land cover inside each polygon.
In ArcGIS Pro, run the Spatial Join tool in the Analysis toolbox. Set the target features and the join features, choose a match option such as Intersect or Within a distance, and choose Join one to one or Join one to many. The tool writes the target features and the joined attributes to a new feature class.
An attribute join matches rows on equal values in a shared column, such as a parcel ID or a ZIP code. A spatial join matches rows on a spatial predicate between two geometry columns. Use an attribute join when a reliable key exists, and a spatial join when the only link between the datasets is location.
Yes. An inner spatial join keeps only pairs that match. A left spatial join keeps every row of the left table and fills the right columns with nulls where nothing matches. A full outer join keeps unmatched rows from both sides. In WherobotsDB, predicate joins, distance joins, and raster-vector joins take LEFT JOIN and FULL OUTER JOIN in SQL. A full outer spatial join runs as a nested loop that tests every pair. On large tables, combine a left join with a left anti join instead. The ST_KNN nearest-neighbor join is an inner join.
With GeoPandas, call geopandas.sjoin with a predicate such as intersects or within and a how of inner, left, or right. GeoPandas runs on one machine. For tables too large for memory, run the same join in PySpark with WherobotsDB, using ST_Intersects or another predicate in the join condition.
predicate
how
A KNN join returns, for each row in one table, the k closest rows in another table. In WherobotsDB, ST_KNN(queries, objects, k, use_sphere) takes the query geometry, the object geometry, the number of neighbors, and a distance model, with an optional search radius. ST_AKNN returns approximate neighbors faster on large tables.
ST_KNN(queries, objects, k, use_sphere)
In ArcGIS Pro, the Add Join tool matches records when a common field holds equal values, such as a parcel ID. The Spatial Join tool matches features by their spatial relationship, such as intersect, within, or within a distance, and needs no common field. Add Join keeps the join in the layer until it is exported; Spatial Join writes a new feature class.
The four types are predicate, distance, k-nearest-neighbor, and raster-vector joins. A predicate join (also called a range join) pairs rows whose shapes pass a test such as intersects or contains. Point in polygon is the common case. A distance join pairs rows within a set distance. A k-nearest-neighbor join returns the k closest rows for each row. A raster-vector join pairs raster tiles with the geometries they overlap and summarizes the pixels. Each can be inner, and all except KNN can be left or full outer in WherobotsDB.
Open Processing, then Vector general, and run Join attributes by location. Choose the input layer, the layer to join, and one or more geometric predicates such as intersect, contain, or are within. Then choose a join type: one feature per match (one-to-many), the first match only, or the match with the largest overlap. Join attributes by location (summary) returns one row per feature with aggregated values.
They filter, then refine. The filter step compares the boxes drawn around each shape, using a spatial index such as an R-tree or quadtree, and keeps only pairs whose boxes overlap. The refine step runs the exact predicate on those candidates. On a cluster, the engine first splits space into cells, for example with a KDB-tree, so each worker joins only nearby rows. Or it copies the smaller table to every worker. In WherobotsDB, EXPLAIN FORMATTED shows the chosen operator, such as BroadcastIndexJoin or RangeJoin.
What is a Geofence? How Geofencing Works, With Examples
A geofence is a virtual boundary that triggers an action when a device enters or leaves it. How geofencing works, its uses and limits, and a test on ship data.
What is Map Matching? GPS Traces, Methods and Examples
Map matching places each GPS record on the road a vehicle drove. How it works, nearest-road snapping vs hidden Markov models, and a test on Ann Arbor drives.
Geospatial Big Data: Why Analytics Is Hard to Scale
Teams struggle to scale geospatial analytics for five reasons: data volume, mixed formats, many coordinate systems, spatial joins and pipeline upkeep.
share this article
Awesome that you’d like to share our articles. Where would you like to share it to: