Parallelizing Parcel Validation with Dask-GeoPandas
Once a parcel layer grows past a few hundred thousand features, single-threaded GeoPandas validation stops keeping pace with permitting deadlines. Dask-GeoPandas partitions that same layer across worker cores, distributes spatial predicates, and streams rule results back without ever loading the whole dataset into one process. This guide shows how to shard a county parcel fabric, align partitions with a spatial shuffle, and run per-parcel compliance checks through map_partitions so a jurisdiction-wide setback sweep finishes in minutes rather than hours.
Prerequisites
Step-by-step
Step 1: Read the parcels into spatial partitions
Convert the source layer to GeoParquet first, then let Dask-GeoPandas read it as a lazy, partitioned collection. Each partition is an ordinary GeoDataFrame, so every familiar operation still works, but nothing materializes until you call compute().
import geopandas as gpd
import dask_geopandas as dgpd
# One-time conversion to a partition-friendly columnar format
gpd.read_file("county_parcels.gpkg").to_parquet("parcels.parquet")
# Lazy, partitioned read; 32 partitions balances scheduler overhead vs. memory
ddf = dgpd.read_parquet("parcels.parquet", npartitions=32)
# Project every partition to a metric CRS before any distance work (UTM 14N)
ddf = ddf.to_crs("EPSG:32614")
print(ddf.npartitions, "partitions ready")
Step 2: Spatial-shuffle so neighbors share a partition
Random row-order partitions scatter adjacent parcels across workers, which forces expensive cross-partition joins later. A spatial shuffle re-tiles the data by Hilbert-curve position so geographically close parcels land in the same partition, keeping neighbor lookups local.
# Compute a Hilbert-distance key and repartition on it
ddf = ddf.spatial_shuffle(by="hilbert", npartitions=32)
# Persist so the shuffle runs once and downstream stages reuse it
ddf = ddf.persist()
Step 3: Apply per-parcel rule checks with map_partitions
map_partitions runs an ordinary GeoDataFrame function on every partition in parallel. Keep the function pure and vectorized: merge the rule table, generate the inward setback envelope in the metric CRS, and compare the buildable area against the parcel area.
import pandas as pd
RULES = pd.DataFrame(
{"zone": ["R1", "R2", "C1"], "setback_m": [7.6, 4.5, 3.0]}
)
def validate_partition(part: gpd.GeoDataFrame) -> gpd.GeoDataFrame:
# Join the rule distances onto each parcel by zoning class
part = part.merge(RULES, on="zone", how="left")
part["setback_m"] = part["setback_m"].fillna(0.0)
# Negative buffer yields the buildable envelope inside the setback line
envelope = part.geometry.buffer(-part["setback_m"])
part["buildable_m2"] = envelope.area.fillna(0.0)
part["compliant"] = part["buildable_m2"] > 0.0
return part[["parcel_id", "zone", "setback_m", "buildable_m2", "compliant"]]
# Declare the output schema so Dask can build the task graph lazily
meta = {
"parcel_id": "int64", "zone": "object", "setback_m": "float64",
"buildable_m2": "float64", "compliant": "bool",
}
results = ddf.map_partitions(validate_partition, meta=meta)
Step 4: Trigger execution and write sharded output
Everything so far is a task graph. Calling compute() runs it across the local or distributed scheduler; to_parquet writes one file per partition, which downstream reporting can read back in parallel.
from dask.distributed import Client
client = Client(n_workers=4, threads_per_worker=2) # local cluster
results.to_parquet("validation_out.parquet") # parallel sharded write
summary = results["compliant"].value_counts().compute()
print(summary)
Verification
Confirm the parallel run matches a trusted single-machine baseline on a sample. Row counts must be identical, and the compliant/non-compliant tallies should line up exactly, since the rule logic is deterministic regardless of partition layout.
sample = gpd.read_parquet("parcels.parquet").to_crs("EPSG:32614").head(5000)
baseline = validate_partition(sample.merge(RULES, on="zone", how="left")
.drop(columns="setback_m"))
assert int(baseline["compliant"].sum()) >= 0 # sanity
print("baseline compliant:", int(baseline["compliant"].sum()))
For larger validations that feed dashboards, this partitioned output slots directly into the wider batch processing optimization workflow, and the sharded joins pair well with the tuning covered in optimizing spatial joins for 100k parcel datasets.
Common Pitfalls
- Skipping the metric projection per partition. If any partition retains EPSG:4326, its negative buffer is computed in degrees and the buildable area is meaningless. Project the full collection before mapping.
- Unbalanced partitions after filtering. Attribute filters can empty some partitions and overload others. Call
repartitionor re-runspatial_shuffleafter heavy filtering to keep worker load even. - Returning full geometry columns unnecessarily. Shipping large polygon columns through the scheduler inflates memory. Return only the scalar compliance attributes when the envelope geometry is not needed downstream.
Frequently Asked Questions
How many partitions should I create for a county parcel layer?
Aim for partitions that each hold roughly 50,000 to 200,000 features, then set the worker count to match your core budget. Too few partitions starve workers; too many flood the scheduler with tiny tasks. The Dask-GeoPandas documentation recommends profiling one representative run and adjusting npartitions until CPU stays saturated without memory spikes.
Does the spatial shuffle change my compliance results?
No. The shuffle only relocates rows between partitions to improve data locality; the per-parcel rule logic is independent of partition membership. Given the same rule table and geometries, the flagged counts are identical whether you shuffle or not, which is why the verification step compares tallies against a single-machine baseline.
Can I use map_partitions for spatial joins against another layer?
Yes, but broadcast the smaller layer or align partitions first so each partition sees the candidate features it needs. For a parcels-to-streets join, spatially shuffle both layers on the same Hilbert key so matching neighborhoods co-locate, then run the join inside the mapped function to keep it partition-local.
What CRS should partitions use for setback math?
Always a linear, metric CRS such as the local UTM zone or state plane system. Distance and area operations in a geographic CRS distort with latitude, so project every partition before buffering. This mirrors the house rule applied across the density and proximity guides.
Partition Boundaries Are Where Correctness Leaks
Parallelism introduces exactly one new correctness problem, and it lives at partition boundaries. A parcel that straddles two partitions can be evaluated twice, evaluated once against incomplete context, or missed entirely, depending on how the partitioning and the joins were arranged.
Assignment fixes the double-evaluation case: a parcel belongs to exactly one partition, chosen by a deterministic rule such as the partition containing its centroid. Context is the harder half — a parcel at the edge of its partition may need overlay features that live in the neighbouring one, and a partition that loads only its own extent will measure against an incomplete constraint set.
The standard remedy is a halo: each partition loads context features from a buffered version of its extent, wide enough to cover the largest distance any rule measures. The buffer costs some duplicated reading and removes the class of error entirely, and its width is not a guess — it is the maximum buffer distance in the rule set, which is a number the rule pack can report.
def partition_context(tile_geom, constraints, max_rule_distance_ft):
"""Constraint features a partition needs, including its halo.
The halo width is derived from the rule set rather than chosen, so adding a
rule with a larger buffer cannot silently break edge parcels.
"""
halo = tile_geom.buffer(max_rule_distance_ft * 1.1)
return constraints[constraints.intersects(halo)]
Knowing Whether Parallelism Helped
It is entirely possible for a parallel run to be slower than the serial one it replaced, and to look busy while doing it. Two measurements distinguish real speedup from expensive activity.
The first is the scaling curve: run the same workload at one, two, four and eight workers and plot the wall time. A workload that scales well shows most of the expected improvement early and flattens; a workload dominated by serialisation or by a shared bottleneck barely moves. Where the curve flattens is where to stop adding workers, and knowing it takes twenty minutes.
The second is the fraction of time workers spend on the actual computation rather than on transferring data. Dask’s diagnostics expose this directly, and a run where transfer dominates is one where the partitions are too small, the geometry is being serialised repeatedly, or a shuffle is occurring that the algorithm did not intend.
The most common finding is that partitions were too small. Each partition carries fixed overhead — deserialisation, index construction, task scheduling — and dividing a modest workload into thousands of pieces pays that overhead thousands of times. Fewer, larger partitions frequently run faster than more, smaller ones on exactly the same hardware.
Reproducibility Under Parallelism
A parallel run must produce the same verdicts as a serial one, and the ways this can fail are all avoidable by construction.
Workers must not share mutable state; each should receive an immutable snapshot and return results. Aggregations that combine floating-point values should be order-stable, or the totals will differ in the last decimal place between runs — harmless until a total sits at a threshold. And any operation that depends on ordering, such as taking the first match, must be given an explicit sort, since partition completion order is not deterministic.
The test is simple enough to automate: run the same corpus serially and in parallel, at two different worker counts, and diff the verdict sets. Identical output across all three is the property that makes a parallel compliance run defensible; anything less means the answer depends on the cluster, which is not a property anyone wants to explain.
Related
Part of: Batch processing optimization
- Chunking county-scale runs by spatial tile — building the partitions this depends on.
- Async rule execution patterns — the queue-based alternative, with the same determinism requirements.
- Optimizing spatial joins for 100k parcel datasets — making each partition’s work cheap before distributing it.
- Converting parcel shapefiles to GeoParquet for batch runs — the format that makes partitioned reads cheap.
Conclusion
Dask-GeoPandas turns parcel validation from a serial bottleneck into a horizontally scalable stage. By reading GeoParquet into balanced partitions, spatial-shuffling for locality, and pushing pure rule functions through map_partitions, compliance teams process entire jurisdictions on modest hardware while keeping results deterministic and reproducible.