Building Multi-Layer Exclusion Masks with GeoPandas Overlay
The scenario is a screening script that runs, produces a buildable-area figure, and disagrees with the environmental consultant’s figure by 14 percent. No exception was raised, both analysts used the same layers, and neither can immediately say why. This page fixes that class of disagreement, and it is the mechanical half of environmental constraint and exclusion screening.
Root-cause analysis
Three mechanisms produce a divergent buildable-area figure, and a real disagreement usually involves more than one.
- Sequential subtraction with a sum. Computing each layer’s area, summing those areas, and subtracting the sum from the gross area double-counts every overlap. Wetlands sit inside floodplains, habitat corridors follow drainage, and the overlap on a typical study area is a quarter to a third of the total constrained area.
- Chained differences.
study.difference(a).difference(b).difference(c)gets the arithmetic right and the geometry wrong: each operation adds vertices and slivers along shared edges, and by the fourth layer the result carries enough topological noise that its area depends on the order the layers were applied in. - A frame that does not preserve area. A hectare figure computed in a conformal frame is latitude-dependent, so two analysts working in different UTM zones or in Web Mercator will legitimately disagree — the same distortion covered under projection and CRS quick reference.
Pre-flight validation
Before any overlay runs, three properties have to hold: every layer carries a declared CRS, every geometry is valid, and every layer has been clipped to the study area. The third is not just a performance measure — an unclipped national layer unioned in full can produce a geometry whose area exceeds the study area, which then makes the accounting assertions meaningless.
import geopandas as gpd
def preflight_constraint_layers(
study: gpd.GeoDataFrame,
layers: dict[str, gpd.GeoDataFrame],
*,
working_epsg: int,
) -> dict[str, dict]:
"""Surface the three faults that make an overlay disagree, before it runs."""
report: dict[str, dict] = {}
study_area = study.to_crs(working_epsg).union_all()
for name, gdf in layers.items():
if gdf.crs is None:
raise ValueError(f"{name}: undeclared CRS — set_crs() the true source frame first")
projected = gdf.to_crs(working_epsg)
invalid = int((~projected.is_valid).sum())
empty = int(projected.geometry.is_empty.sum())
intersects = int(projected.geometry.intersects(study_area).sum())
report[name] = {
"features": len(projected),
"invalid": invalid,
"empty": empty,
"intersecting_study_area": intersects,
"source_epsg": gdf.crs.to_epsg(),
}
if intersects == 0:
# Not fatal on its own — a national layer may genuinely miss this county.
report[name]["warning"] = "no features intersect the study area"
return report
A layer reporting zero intersecting features is normal in isolation and alarming in aggregate: when every layer reports zero, the study area is almost certainly in a different frame from the constraints, not in an unconstrained paradise.
Fix implementation
The correct shape is: clip, repair, union per class, subtract once. GeoPandas overlay with
how="difference" does the subtraction, and union_all() does the union — the important part is
that the union happens before the difference, exactly once.
import geopandas as gpd
EQUAL_AREA_EPSG = 5070
def build_exclusion_mask(
study: gpd.GeoDataFrame,
layers: dict[str, gpd.GeoDataFrame],
*,
working_epsg: int,
buffer_m: dict[str, float] | None = None,
) -> tuple[gpd.GeoDataFrame, dict[str, float]]:
"""Return the buildable remainder plus a per-layer hectare accounting."""
buffer_m = buffer_m or {}
study_p = study.to_crs(working_epsg)
study_geom = study_p.union_all()
per_layer: dict[str, float] = {}
pieces = []
for name, gdf in layers.items():
layer = gdf.to_crs(working_epsg)
layer["geometry"] = layer.geometry.make_valid()
# Clip first: this is the single largest cost saving in the whole function.
clipped = layer.clip(study_geom)
if clipped.empty:
per_layer[name] = 0.0
continue
geom = clipped.union_all()
if buffer_m.get(name):
geom = geom.buffer(buffer_m[name]) # working room, in metres
per_layer[name] = _ha(geom, working_epsg)
pieces.append(geom)
if not pieces:
return study_p.assign(exclusion_ha=0.0), per_layer
excluded = gpd.GeoSeries(pieces, crs=study_p.crs).union_all()
buildable = gpd.GeoDataFrame(
{"excluded_ha": [_ha(excluded, working_epsg)]},
geometry=[study_geom.difference(excluded)],
crs=study_p.crs,
)
return buildable, per_layer
def _ha(geom, working_epsg: int) -> float:
return float(
gpd.GeoSeries([geom], crs=working_epsg).to_crs(EQUAL_AREA_EPSG).area.iloc[0]
) / 10_000.0
Two details carry the correctness. make_valid() runs before any union, because unioning an invalid
ring produces an area that is wrong without raising. And _ha reprojects to an equal-area frame for
every measurement, so the working frame stays free for distance operations without contaminating the
hectare figures.
Fallback routing and performance tuning
- Clip before you union. A national wetlands layer has millions of vertices; the part inside one county has thousands. Clipping first turns the union from the dominant cost into a rounding error.
- Simplify only what carries no legal weight. A
simplify(tolerance=1.0)on an advisory viewshed layer is free; the same call on a wetland delineation changes a regulated boundary. - Use the spatial index implicitly.
GeoDataFrame.clipalready queries the index, so an explicitsindex.querybefore it buys nothing — the common mistake is doing neither and callingintersectionon the full layer. - Union in one call, not in a loop.
union_all()on a list is substantially faster than repeated pairwise unions, because it can use a cascaded strategy rather than rebuilding the accumulated geometry each time. - Keep the mask, not the difference, when reusing. For a portfolio, computing the exclusion union once and differencing it against each parcel is far cheaper than rebuilding the union per parcel.
Downstream validation
The accounting is what makes two analysts agree. Publish the gross area, each layer’s clipped area, the union area, and the difference between the per-layer sum and the union — that difference is the overlap, and it is the number the disagreement was always about.
def assert_mask_reconciles(gross_ha: float, per_layer: dict[str, float], excluded_ha: float,
buildable_ha: float) -> None:
"""CI gate: the three relations that must hold if the arithmetic is right."""
assert buildable_ha <= gross_ha * 1.0001, "buildable exceeds gross — wrong frame for area"
assert excluded_ha <= gross_ha * 1.0001, "exclusion exceeds gross — a layer was not clipped"
assert sum(per_layer.values()) >= excluded_ha * 0.9999, (
"per-layer sum below the union — a layer was measured in a different CRS"
)
assert abs((gross_ha - excluded_ha) - buildable_ha) < max(0.01, gross_ha * 1e-6), (
"gross − excluded ≠ buildable — the difference and the union disagree"
)
Frequently asked questions
Why does overlay(how="difference") return more rows than the input?
Because the difference can split one polygon into several disjoint parts, and GeoPandas returns them as separate rows unless the geometry is explicitly recombined. That is usually what you want for a buildable-area map and never what you want for a per-parcel accounting — dissolve back to the input key before aggregating, or the same parcel appears several times in the totals.
Should exclusions be buffered before or after the union?
Before, per layer, because different layers need different working-room offsets: a wetland delineation needs the regulatory buffer, a road needs the construction offset, and a viewshed needs none at all. Buffering after the union applies one offset to everything and quietly grows the exclusion by more than any single rule requires.
What is the fastest way to test one parcel against a prepared mask?
Prepare the mask geometry once and test parcels against the prepared version. The mask is reused thousands of times in a portfolio run, which is exactly the case prepared geometry exists for — the preparation cost is amortised after roughly twenty tests.
How should the mask handle holes?
Leave them as authored. A hole inside a wetland polygon is upland the delineation deliberately excluded, and it is buildable unless another layer says otherwise. Filling holes “to clean up the geometry” is a common and expensive tidying instinct: it removes real buildable land and is invisible in the final map.
Does the mask need to be a single polygon?
No, and forcing it to be one is usually a mistake. A study area with several disjoint buildable pockets is genuinely a MultiPolygon, and dissolving it into one geometry with a convex hull or a generous buffer merges pockets that are separated by real constraints. Keep the parts, and report their count and their individual areas — a single 400-hectare pocket and eight 50-hectare pockets are very different projects.
How should the mask be stored between runs?
As a versioned artefact keyed on the layer versions that produced it, in the working CRS, with the per-layer accounting alongside. Recomputing a national mask for every parcel is the most common performance mistake in this stage, and caching the geometry without caching what produced it is the most common correctness one — a mask whose provenance is unknown cannot be reused with confidence.
What tolerance should be used when comparing two analysts’ figures?
Small: a fraction of a percent. Once both figures are computed in an equal-area frame from the same layer versions with the same buffers, they should agree to within floating-point noise. A gap larger than that is not tolerance — it is a difference in inputs or in method, and chasing it down is how the discrepancy in the opening paragraph gets resolved.
Related
- Environmental Constraint & Exclusion Screening — the parent workflow and its classification rules
- Detecting & Removing Sliver Polygons in GeoPandas — cleaning the slivers a chained difference creates
- Clipping Solar Parcels to County Setback Boundaries in GeoPandas — the same union-then-subtract discipline for statutory setbacks
- Projection & CRS Quick Reference — choosing the equal-area frame the accounting needs