Parallel Spatial Joins with Dask-GeoPandas
A spatial join between two out-of-memory datasets is the operation people reach for Dask-GeoPandas to do — and the one that disappoints if you skip the spatial shuffle. This guide runs a points-in-polygons join across partitioned data correctly and in parallel, for analysts joining millions of features that won't fit in a single GeoPandas GeoDataFrame. It sits under Scaling with Dask-GeoPandas in Spatial Analysis & Advanced Query Techniques.
Why This Approach / What Goes Wrong
dask_geopandas.sjoin mirrors the in-memory geopandas.sjoin, and it keeps the exact same join semantics covered in Spatial Joins & Merging — only the distribution changes, not what within or intersects mean. That is precisely where the trap is: parallel correctness depends on how the two inputs are partitioned, something the in-memory API never asks you to think about.
If both inputs are partitioned arbitrarily — typically in row order, so partition 3 holds features scattered across the whole map — the scheduler has no way to know which right partition might match a given left partition. It must test every left partition against every right partition. That all-pairs comparison is O(n²) in partitions and throws away the parallelism you came for; it is routinely slower than just loading the data once and joining on a single machine.
spatial_shuffle is the fix. It repartitions both inputs along a Hilbert space-filling curve so that spatially close features share a partition and each partition gets a compact, non-overlapping bounding box. After the shuffle, a left partition only has to be tested against the handful of right partitions whose boxes actually overlap it; the scheduler prunes everything else. Forgetting the shuffle is the entire difference between a join that scales and one that crawls.
The other classic failure is a CRS mismatch between the two inputs. sjoin evaluates its predicate on raw coordinates, so if the layers disagree on their coordinate reference system every predicate is false — and unlike a broken import, this raises no error. You get an empty or wrong result and no traceback to explain it.
There is a third failure that only appears at scale, and it hits after the join succeeds. A spatial join is not a filter, it is a many-to-many product: each output row is one matched pair. Points inside polygons with within are close to one-to-one, so the output is roughly the size of the input. Polygon-to-polygon with intersects is not — a partition of 400,000 building footprints joined against overlapping zoning districts can emit two or three million rows, and the partition that was sized to fit comfortably in a worker on the way in blows past memory_limit on the way out. The number worth knowing before you launch the full run is the fan-out ratio: output rows divided by left input rows, measured on a single partition. Anything above 1 means your output partitioning is no longer the partitioning you designed, and you should raise npartitions on the left input by that same factor.
Prerequisites
dask-geopandas>=0.4geopandas>=0.14pyarrow>=15(GeoParquet I/O — not optional)- Optional:
distributed>=2024.1for a multi-machine cluster and the task dashboard
conda install -c conda-forge "dask-geopandas=0.4.*" "geopandas=0.14.*" "pyarrow=15.*"
Both inputs should already be stored as partitioned GeoParquet; it is the on-disk format that carries the CRS in metadata and lets the reader skip partitions by bounding box, which keeps the whole pipeline lazy and prunable.
Step-by-Step Implementation
1. Read both partitioned inputs and align their CRS.
Reproject before anything else. A Hilbert ordering computed in degrees is not the same ordering you get in metres, and a join across mismatched systems returns silent garbage. Use a proper metric CRS for the region — here ETRS89 / UTM 32N (EPSG:25832) — never Web Mercator (EPSG:3857) for measurement. See Coordinate Systems with PyProj for the CRS conventions.
import dask_geopandas as dgpd
# ~12M GPS pings and ~50k administrative zones, both as partitioned GeoParquet
pings = dgpd.read_parquet("gps_pings/")
zones = dgpd.read_parquet("admin_zones/")
# Joins require a shared CRS — reproject both to the same metric system.
# to_crs runs per-partition and in parallel; it is still lazy here.
pings = pings.to_crs(epsg=25832)
zones = zones.to_crs(epsg=25832)
2. Spatially shuffle so partitions align by location.
Shuffle both sides with a comparable npartitions so their bounding boxes line up. The shuffle is a full data movement, so do it once, here, before the join — not repeatedly.
# Hilbert-curve repartitioning: spatially close features land together.
pings = pings.spatial_shuffle(npartitions=128)
zones = zones.spatial_shuffle(npartitions=128)
The two npartitions values do not have to match, and forcing them to is a common mistake — 50,000 zone polygons split 128 ways gives partitions of 390 features each, which is far below the point where a task is worth scheduling. What has to line up is the geography: both sides ordered on the same curve so their boxes tile the same extent. Size each side from its own byte budget, using the measurement recipe in Partitioning Strategies for Dask-GeoPandas, and let the shuffle handle the alignment.
3. Run the spatial join lazily.
# Assign each ping the zone that contains it.
# predicate="within" tests point-within-polygon; the point layer is the left side.
joined = dgpd.sjoin(pings, zones, how="inner", predicate="within")
Two arguments carry all the semantics here, and both behave the way they do in-memory. predicate is evaluated left relative to right, so within on a point-left/polygon-right call keeps points inside polygons; swap the operands without swapping the predicate and every test fails. how="inner" drops pings that fall in no zone; how="left" keeps them with null zone columns, which is what you want when the unmatched count is itself the answer — how many pings landed outside the coverage area. Be aware of what a left join does to dtypes: the null-filled right-hand columns force an integer zone_id up to float64, so a later groupby("zone_id") silently groups on floats and any downstream write records the wrong type. Cast it back explicitly with .astype("Int64") (the nullable integer) immediately after the join. A right join is not supported by the distributed implementation — reverse the operands and use a left join instead, which computes the same thing.
4. Aggregate and materialize. Stream the result to disk rather than pulling it into the driver process. Reserve .compute() for small reductions; use .to_parquet() whenever the result is itself large.
counts = (
joined.groupby("zone_id")
.size()
.rename("ping_count")
.reset_index()
)
counts.to_parquet("ping_counts/", write_index=False)
5. (Optional) Use a distributed client for a real cluster.
A single workstation's threaded scheduler handles a surprising amount of GEOS-bound work, because the heavy lifting releases the GIL. Add dask.distributed only when one box runs out of cores or RAM, or when you want the live dashboard.
# from dask.distributed import Client
# client = Client(n_workers=8, threads_per_worker=2, memory_limit="4GB")
# print(client.dashboard_link) # http://127.0.0.1:8787/status
# ...the same code above now runs across workers...
6. When one side is small, broadcast it instead of shuffling.
The shuffle is the most expensive stage in the pipeline above, and it is avoidable whenever the right-hand layer fits in a worker's memory with room to spare. 50,000 administrative zones is a few hundred megabytes of geometry — send a copy to every worker once, and each left partition joins against the whole zone layer locally. No data movement, no partition alignment to get wrong, and the left side keeps whatever layout it arrived with.
import geopandas as gpd
import dask_geopandas as dgpd
from dask.distributed import Client
client = Client(n_workers=8, threads_per_worker=2, memory_limit="8GB")
pings = dgpd.read_parquet("gps_pings/").to_crs(epsg=25832) # no shuffle needed
zones = gpd.read_parquet("admin_zones.parquet").to_crs(epsg=25832)
# Ship the small layer to every worker once, not once per task
zones_remote = client.scatter(zones, broadcast=True)
def join_against_zones(part: gpd.GeoDataFrame, zone_layer) -> gpd.GeoDataFrame:
return gpd.sjoin(part, zone_layer, how="inner", predicate="within")
sample = pings.partitions[0].head(50)
joined = pings.map_partitions(
join_against_zones, zones_remote,
meta=join_against_zones(sample, zones).head(0),
)
joined.to_parquet("pings_with_zone/", write_index=False)
The rule of thumb: broadcast when the right-hand layer is under roughly a tenth of one worker's memory_limit, because every worker holds a full copy for the duration of the job on top of its share of the left side. Above that, the shuffle is cheaper than the replication. Note broadcast=True on the scatter — without it, Dask sends the layer to a few workers and the rest pull it over the network on demand, which reintroduces the transfer you were avoiding. Skipping client.scatter entirely and closing over zones in the function is the version that looks simplest and performs worst: the whole layer is then serialised into every single task.
Verification
Confirm the join actually matched features and that the totals reconcile against the input. Checking a reduction is cheap — it never materializes the full joined dataset.
import dask_geopandas as dgpd
result = dgpd.read_parquet("ping_counts/").compute()
print("Zones with pings:", len(result)) # Zones with pings: 47213
print("Total joined pings:", int(result["ping_count"].sum())) # Total joined pings: 11984502
# With predicate="within" each ping matches at most one zone, so the joined
# total must not exceed the input ping count.
total_pings = dgpd.read_parquet("gps_pings/").shape[0].compute()
assert result["ping_count"].sum() <= total_pings
A joined total of zero almost always means the two inputs were in different CRSs — go back to step 1 and assert crs.is_projected on a single partition with pings.partitions[0].compute().crs.
Run the cheaper check before the full job, not after it. One partition, joined on its own, tells you the fan-out ratio and the boundary-duplication rate for the price of a few seconds — and both numbers scale linearly, so a single partition predicts the whole run.
import dask_geopandas as dgpd
pings = dgpd.read_parquet("gps_pings/").to_crs(epsg=25832)
zones = dgpd.read_parquet("admin_zones/").to_crs(epsg=25832)
# Join ONE aligned partition pair and read the shape of the result
left = pings.partitions[0].compute()
probe = dgpd.sjoin(
dgpd.from_geopandas(left, npartitions=1), zones, predicate="within"
).compute()
fan_out = len(probe) / len(left)
dupes = int(probe.index.duplicated().sum())
print(f"fan-out {fan_out:.2f}x, {dupes} pings matched more than one zone")
# fan-out 1.00x, 3 pings matched more than one zone
A fan-out of 1.00 confirms the join really is one-to-one and that output partitions will be the size you planned. The three duplicates are pings that landed exactly on a shared zone boundary — a rounding artefact of coordinates stored at limited precision, not a data error, and the reason the assertion above uses <= rather than ==. If the fan-out comes back above 1, multiply your left-side npartitions by it before the real run so the output partitions land in the target band rather than the input ones.
Edge Cases & Debugging
- Join is slower than single-machine GeoPandas. You skipped
spatial_shuffle; without it the join is all-pairs across every partition. Shuffle both inputs first. - Empty result. CRS mismatch between inputs, or the predicate is reversed (
withinvscontains— the direction depends on which layer is the left side). - Workers OOM during the shuffle. The shuffle is memory-intensive; raise
npartitionsso each chunk is smaller and fits below the per-workermemory_limit. - Duplicate matches. Points on a shared zone border can match two polygons; resolve with a tie-break rule or
drop_duplicateson the point id after the join. index_rightcolumn collisions. Reset indices before joining, exactly as with in-memorysjoin.- Result order differs between runs. Parallel execution does not preserve row order; sort explicitly if a downstream step depends on it.
- A handful of tasks take ten times longer than the rest. The shuffle equalises row counts, not work. A partition covering a dense city holds the same number of pings as a rural one but overlaps far more zone polygons, so its join does more candidate tests. Confirm it on the dashboard task stream, then split the left side further — the extra partitions cost nothing on the fast tasks.
- The join succeeds and the write runs out of memory. Output partitions, not input ones, are too large; that is the fan-out ratio biting. Re-run with more left-side partitions.
zone_idcomes back asfloat64after a left join. Unmatched rows introduced nulls. Cast with.astype("Int64")before grouping or writing.- Reading the joined output back gives a plain DataFrame. The write lost the geometry column's spatial metadata because a
map_partitionsstep in between declared a non-spatialmeta. Checktype(joined._meta)before writing.
Frequently Asked Questions
Do I really need spatial_shuffle on both inputs?
Yes, when both are large. Shuffling only one side leaves the other in arbitrary row order, so partition bounding boxes never line up and the executor falls back to comparing many partition pairs — you pay the shuffle and keep the all-pairs cost. The exception is the broadcast case in step 6, where the small side is not partitioned at all.
How do I decide between shuffling and broadcasting?
Compare the size of the smaller layer against one worker's memory_limit. Under roughly a tenth of it, broadcast: every worker holds a copy, and you skip a full data movement. Above it, the replication costs more than the shuffle it avoids, and the shuffle also leaves you with an aligned dataset that later jobs reuse.
Why within and not intersects?
For a strict points-in-polygons assignment, within matches a point only when it lies inside a polygon, avoiding the boundary double-counting that intersects produces. Choose the predicate that matches your question, then handle boundary ties explicitly.
Should I use Dask-GeoPandas or PostGIS for this join? If the data is shared, updated, and queried by many clients, load it into PostGIS and let its GiST index serve the join. For a one-shot batch over files you already hold, Dask-GeoPandas avoids standing up a database. For in-process SQL analytics over local GeoParquet, DuckDB Spatial Analytics is the lighter tool.
Can I join a partitioned layer against one that is not partitioned at all?
Yes — that is exactly the broadcast path. Read the small side with plain GeoPandas, scatter it, and join each partition against it inside map_partitions. Do not convert it to a Dask collection first just to make the types match: a one-partition collection reintroduces the alignment problem for nothing.
How do I get the number of points per polygon without materialising the join?
Chain the aggregation onto the lazy join and only compute the reduction, as in step 4. groupby(...).size() produces one row per zone — small enough to pull into the driver — and the executor never has to hold the full matched-pair table anywhere. Materialise the pair table only when you actually need per-pair attributes.
Does the joined output keep the left side's partition layout? The row-to-partition mapping survives, but the sizes do not: each output partition holds however many matched pairs its input partition produced. When the fan-out is uneven — dense zones producing more matches — the output is skewed even though the input was balanced. Repartition before writing if downstream jobs depend on even files.