Chunking County-Scale Runs by Spatial Tile
Splitting a county run into tiles is the cheapest way to make it parallel, resumable and bounded in memory — and the easiest way to change the answer without noticing. Every operation in a compliance pipeline that looks at a parcel’s neighbours (setback encroachment, sliver detection, nearest-feature distance) reads across the tile edge, so a naive partition silently drops the relationships that cross it. This guide builds tiles that balance work rather than area, buffers each tile so edge parcels see their neighbours, and asserts that the partitioned run returns exactly what the whole-county run returns. It is the partitioning half of batch processing optimization.
Prerequisites
Step-by-step
Step 1: Determine the interaction distance before choosing tiles
The buffer that makes tiling safe is determined by the rules, not by the geometry. Work it out explicitly and let it fail loudly if a rule exceeds it.
def interaction_distance(rules) -> float:
"""The furthest any rule reaches from the parcel it is evaluating."""
reaches = []
for r in rules:
if r["kind"] == "setback":
reaches.append(r["threshold"])
elif r["kind"] == "proximity":
reaches.append(r["buffer_ft"])
elif r["kind"] == "adjacency":
reaches.append(r.get("search_ft", 0.0))
return max(reaches) if reaches else 0.0
HALO_FT = interaction_distance(RULES) * 1.5 # margin for geometry that overruns
The multiplier is there because a rule’s threshold is measured from a parcel boundary, and a parcel near a tile edge may itself extend past that edge. A halo of 1.5× the largest threshold covers both effects on any normal fabric, and a check in step 5 confirms it did.
Step 2: Balance the tiles by parcel count, not by area
A uniform grid over a county gives one tile with 40,000 parcels and forty tiles with none, so the run takes as long as its worst tile. Splitting recursively on the median coordinate — a KD-tree split — gives tiles of similar population and wildly different sizes, which is exactly what balances the work.
import numpy as np
from shapely.geometry import box
def balanced_tiles(gdf, max_per_tile=5000):
"""Recursive median splits on the longer axis until every tile is small enough."""
def split(sub, bounds):
if len(sub) <= max_per_tile:
return [(box(*bounds), sub.index)]
minx, miny, maxx, maxy = bounds
pts = sub.geometry.representative_point()
if (maxx - minx) >= (maxy - miny):
cut = float(np.median(pts.x))
left, right = pts.x < cut, pts.x >= cut
return (split(sub[left], (minx, miny, cut, maxy))
+ split(sub[right], (cut, miny, maxx, maxy)))
cut = float(np.median(pts.y))
low, high = pts.y < cut, pts.y >= cut
return (split(sub[low], (minx, miny, maxx, cut))
+ split(sub[high], (minx, cut, maxx, maxy)))
return split(gdf, tuple(gdf.total_bounds))
The tiles tessellate exactly — every point of the county is in one tile and no point is in two — which is the property the assignment rule in step 4 depends on. Where an H3 index is already on the parcels, its coarser resolutions make a serviceable alternative partition; the trade-off is that H3 cells balance by area rather than by population, so they inherit the problem this step exists to solve unless population is checked.
Step 3: Read each tile with its halo
The tile defines which parcels are evaluated; the halo defines which are available. Reading with a bounding-box filter pushes the selection into the file format, which is where GeoParquet earns its place — see converting parcel shapefiles to GeoParquet for batch runs.
import geopandas as gpd
def read_tile(path, tile_geom, halo_ft):
haloed = tile_geom.buffer(halo_ft)
gdf = gpd.read_parquet(path, bbox=haloed.bounds)
gdf = gdf[gdf.intersects(haloed)]
# Evaluate only what the tile owns; the rest is context.
gdf["in_tile"] = gdf.geometry.representative_point().within(tile_geom)
return gdf
Reading by bounding box rather than filtering after a full read is the difference between a tile that loads in under a second and one that loads the whole county forty times.
Step 4: Assign every parcel to exactly one tile, deterministically
The halo means a parcel appears in several tiles. Exactly one of them must own it, and ownership must not depend on which tile finished first.
def evaluate_tile(gdf, rules):
"""Evaluate everything for context; emit results only for parcels this tile owns."""
results = run_rules(gdf, rules) # uses the full haloed frame
return results[results["parcel_id"].isin(gdf.loc[gdf["in_tile"], "parcel_id"])]
Ownership by representative point is deterministic because the tiles tessellate and a representative point is always inside its own parcel: every parcel’s point falls in exactly one tile regardless of evaluation order. Ownership by intersection would not be — a parcel straddling an edge intersects two tiles and would be emitted twice.
Step 5: Assert the halo was big enough
The halo is an assumption about the rules, and assumptions about rules should be checked at runtime rather than believed.
def check_halo(gdf, results, tile_geom, halo_ft):
"""Nothing a tile relied on may have come from beyond the halo."""
used = gdf[gdf["parcel_id"].isin(results["neighbour_id"].dropna())]
max_reach = used.geometry.distance(tile_geom.boundary).max()
if max_reach > halo_ft * 0.9:
raise RuntimeError(
f"a rule reached {max_reach:.1f} ft from the tile edge; halo is {halo_ft:.1f} ft")
Failing at 90% of the halo rather than at 100% gives warning before the partition actually starts changing answers. This check is what makes the tiling defensible: without it, the correctness of a county run rests on somebody having remembered every rule’s reach.
Step 6: Write per-tile output that survives a retry
A tile that fails must be re-runnable without corrupting what succeeded, which means the output has to be addressed by tile rather than appended to a shared file.
import os
def write_tile_result(results, out_dir, tile_id, run_id):
path = f"{out_dir}/run={run_id}/tile={tile_id:05d}/results.parquet"
os.makedirs(os.path.dirname(path), exist_ok=True)
tmp = path + ".tmp"
results.to_parquet(tmp, index=False)
os.replace(tmp, path) # atomic: a reader never sees a partial file
return path
Writing to a temporary name and renaming is what makes a tile’s output atomic. It is the same discipline the queueing in building async rule queues for batch zoning validation relies on, and it turns a failed run into a resumable one: re-running only the tiles with no result file is correct by construction.
Verification
The partitioned run must return exactly the unpartitioned run. That is testable on a sample area and should be a standing test.
whole = run_rules(gpd.read_parquet(PARCELS), RULES).sort_values("parcel_id")
tiled = pd.concat([pd.read_parquet(p) for p in tile_outputs]).sort_values("parcel_id")
assert len(tiled) == len(whole), f"tiled emitted {len(tiled)} vs {len(whole)}"
assert tiled["parcel_id"].is_unique, "a parcel was emitted by two tiles"
pd.testing.assert_frame_equal(
tiled.reset_index(drop=True), whole.reset_index(drop=True), check_like=True)
# Balance: the slowest tile bounds the run.
sizes = [len(pd.read_parquet(p)) for p in tile_outputs]
print(f"{len(sizes)} tiles, {min(sizes)}–{max(sizes)} parcels, "
f"imbalance {max(sizes) / (sum(sizes) / len(sizes)):.2f}x")
An imbalance ratio near 1.2 or below means the median splits did their job. Above 2, the run is waiting on one tile and the max_per_tile threshold wants lowering.
Common Pitfalls
- Tiling without a halo. Every neighbour relationship crossing a tile edge disappears, and the result looks complete because every parcel still got a verdict.
- Assigning parcels by intersection. A parcel straddling an edge is emitted by both tiles, so it appears twice with possibly different results.
- Uniform grids over a county. Population per tile varies by orders of magnitude and the run takes as long as its densest tile.
- Appending all tiles to one file. A retried tile then duplicates its rows, and a failed run cannot be resumed without redoing everything.
- Deriving the halo from the tile size. It comes from the rules’ reach. A larger tile does not make a rule look further.
- Filtering after a full read. Reading the whole county per tile turns a partitioning optimisation into a slowdown proportional to the tile count.
Frequently Asked Questions
How large should a tile be?
Small enough that a tile plus its halo fits comfortably in one worker’s memory, large enough that the halo is a small fraction of the tile. A few thousand parcels usually satisfies both; below about a thousand, the halo starts dominating the read and the overhead grows.
Does the halo make the run substantially more expensive?
It makes each tile read more parcels than it evaluates, and the overhead is roughly the ratio of the halo’s ring area to the tile’s area. For a 5,000-parcel tile and a 200-foot halo, that is a few percent — negligible against the parallelism gained. It becomes significant only when tiles get small or a rule has a very long reach.
Can I tile by administrative boundary instead?
You can, and it is attractive because the outputs align with how results are consumed. The costs are that municipalities vary in size by orders of magnitude, which reintroduces the balance problem, and that boundary-crossing relationships are exactly the ones a jurisdiction-based partition hides — see jurisdictional boundary and precedence resolution.
How does this interact with Dask?
Well, but the halo has to be explicit — Dask-GeoPandas partitions spatially and will happily give you partitions with no overlap. The pattern that works is to compute tiles as above, then map the per-tile function over them rather than relying on the framework’s own partitioning. Parallelizing parcel validation with Dask-GeoPandas covers the execution side.
Should tiles be recomputed each run?
Only when the fabric changes materially. Stable tile boundaries make run-to-run comparison much easier, because a result diff is then per-tile and a changed tile localises the change. Recomputing the partition every run means every result moves, and the diff tells you nothing — which matters for detecting rule drift between pipeline releases.
What if a rule genuinely has unbounded reach?
Then it cannot be tiled and should be run as a separate whole-county pass. Nearest-feature queries are the usual case: the nearest protected feature could in principle be arbitrarily far, though in practice a generous cut-off plus a fallback for the parcels that find nothing works well — the approach in measuring distance to the nearest protected feature. Trying to force such a rule into the tiled pass is how a halo assumption gets quietly violated.
Related
Part of: Batch processing optimization
- Parallelizing parcel validation with Dask-GeoPandas — executing the tiles this builds.
- Converting parcel shapefiles to GeoParquet for batch runs — the format that makes bounding-box reads cheap.
- Optimizing spatial joins for 100k parcel datasets — making each tile fast once it is loaded.
- Aggregating density to H3 hexagons — an index that doubles as a partition key.
- Building async rule queues for batch zoning validation — the retry semantics this depends on.