Computing Overlay Union & Difference with GeoPandas

geopandas.overlay implements the set-theoretic operations — intersection, union, difference, symmetric difference, and identity — that cut two polygon layers against each other into a new one. This guide runs each mode on realistic zoning-versus-floodplain layers and shows the topology hygiene that keeps the output free of slivers and TopologyExceptions. It is for anyone overlaying zoning against hazard areas, land use against parcels, or administrative boundaries against a watershed. It sits under Geometric Intersections & Overlays in Spatial Analysis & Advanced Query Techniques.

Why This Approach / What Goes Wrong

overlay is not the same as a spatial join. A spatial join attaches attributes wherever geometries relate but leaves the original geometries intact; an overlay rebuilds geometry, cutting features against each other and emitting new polygons at every boundary crossing. A single zone that straddles three flood zones comes back as three rows — that row multiplication is the mental model everything downstream (area accounting, attribute reconciliation, deduplication) follows from.

Two things break overlays, and neither is checked for you by the API. The first is invalid input geometry: self-intersecting rings and bad orientation turn a union into a GEOS TopologyException, or — worse — silently corrupt the result before it raises. The second is an unprojected coordinate system: because the engine underneath is GEOS (the same C library behind Shapely geometry operations), every mode assumes a planar Cartesian space, so an overlay computed in EPSG:4326 produces areas in square degrees that grow more distorted with latitude. Validate first with the tooling from Topology Validation & Repair, work in a projected metric CRS, and overlay becomes the precise instrument it is meant to be.

What each geopandas.overlay mode keeps Two overlapping polygon layers — A, the zoning layer in cyan, and B, the floodplain in violet — drawn five times. Each panel fills only the region that survives one overlay mode. Intersection keeps just the shared overlap; union keeps every piece of both; difference keeps A outside B; symmetric difference keeps whatever lies in exactly one layer; identity keeps all of A but tags the overlap with B's attributes, so its footprint matches A while carrying the extra column. The five overlay modes on one pair of layers A = zoning B = floodplain A B Intersection A ∩ B A B Union A ∪ B A B Difference A − B A B Symmetric diff. A △ B A B Identity all of A, tagged by B
Every mode reshapes the same two layers differently. Identity is the outlier: its footprint equals A, but the overlap carries B's attributes — an intersection glued back onto the leftover difference of A.

Prerequisites

conda install -c conda-forge "geopandas>=0.14" "shapely>=2.0" "pyproj>=3.6"

Installing the whole GEOS/GDAL/PROJ stack from a single conda-forge channel keeps the C libraries aligned; mixing a pip GEOS build with a conda one can shift overlay results subtly between versions.

Step-by-Step Implementation

1. Load two polygon layers and align both to one projected CRS.

GeoPandas will not reproject inside overlay, so the alignment is your responsibility. estimate_utm_crs() picks the correct UTM zone automatically; never overlay in a geographic CRS, and avoid Web Mercator (EPSG:3857) for the cut because its area distortion away from the equator makes every measured result unreliable. The full axis-order and always_xy mechanics live in Coordinate Systems with PyProj.

import geopandas as gpd

# Administrative zoning and FEMA flood hazard areas as GeoDataFrames
zoning = gpd.read_file("zoning.gpkg")
floodplain = gpd.read_file("floodplain.gpkg")

# Bring both onto ONE metric CRS so areas come out in m², not degrees
if not zoning.crs.equals(floodplain.crs):
    floodplain = floodplain.to_crs(zoning.crs)
if zoning.crs.is_geographic:
    metric = zoning.estimate_utm_crs()      # auto-pick the right UTM zone
    zoning = zoning.to_crs(metric)
    floodplain = floodplain.to_crs(metric)

2. Validate both layers before overlaying.

Repair invalid geometry with make_valid() rather than the legacy buffer(0) trick — it preserves geometry type and is deterministic. Deeper repair strategies are covered in Fixing Self-Intersecting Polygons Programmatically.

The validity gate that belongs in front of every overlay A left-to-right pipeline. The zoning and floodplain layers enter a per-row is_valid test. Rows that fail branch upward through make_valid, which rebuilds the ring while preserving the geometry type; rows that pass go straight through. Both branches rejoin at a step that drops empty and degenerate leftovers, and only then does gpd.overlay run the cut. Repair before the cut, never after it input layers zoning + floodplain is_valid? per row make_valid() rebuilds the ring passes through untouched clean layers no empty, no null gpd.overlay clean cut invalid valid make_valid() keeps the geometry type and is deterministic; the old buffer(0) trick is neither.
Validity is a gate, not a cleanup step: rows that fail is_valid take the repair branch, and only geometry that survives both branches reaches the overlay.
from shapely import make_valid

for layer in (zoning, floodplain):
    invalid = ~layer.geometry.is_valid
    if invalid.any():
        layer.loc[invalid, "geometry"] = layer.loc[invalid, "geometry"].apply(make_valid)

# Drop degenerate geometry that would poison the cut
zoning = zoning[zoning.geometry.is_valid & ~zoning.geometry.is_empty]
floodplain = floodplain[floodplain.geometry.is_valid & ~floodplain.geometry.is_empty]

3. Intersection — the area in both layers (zoning and flood risk).

Intersection keeps only the shared area and carries attributes from both inputs. Because it emits new polygons, area is meaningful to compute immediately after.

zoning_at_risk = gpd.overlay(zoning, floodplain, how="intersection", keep_geom_type=True)
zoning_at_risk["risk_area_m2"] = zoning_at_risk.geometry.area

4. Difference — zoning outside the floodplain.

Difference subtracts the second layer from the first, so zoning − floodplain is the buildable land clear of the hazard. Order matters: overlay(a, b, "difference") keeps the part of a not covered by b.

zoning_safe = gpd.overlay(zoning, floodplain, how="difference", keep_geom_type=True)

5. Union — every distinct piece from both, attributes preserved.

Union yields all intersection pieces plus the non-overlapping remainder of each input, with each output row tagged by whichever inputs contributed to it (unmatched attributes come back as NaN).

combined = gpd.overlay(zoning, floodplain, how="union", keep_geom_type=True)

6. Symmetric difference and identity — the two remaining modes.

Symmetric difference keeps area in exactly one layer but not both; identity cuts the first layer by the second while staying bounded to the first layer's extent (an intersection plus the leftover difference of the left input).

either_not_both = gpd.overlay(zoning, floodplain, how="symmetric_difference", keep_geom_type=True)
zoning_tagged = gpd.overlay(zoning, floodplain, how="identity", keep_geom_type=True)

7. Reconcile attributes and export.

Overlay multiplies rows, so collapse the pieces back with dissolve (which, unlike a plain groupby, keeps the result a GeoDataFrame and preserves the CRS) and persist to GeoParquet, which carries the CRS in the file metadata.

Row multiplication through an overlay, then dissolve back Three stacked tables read left to right. The input holds two zoning rows, A-12 and A-13. The union overlay cuts them against the floodplain and returns five rows, three carved out of A-12 and two out of A-13, one per boundary crossing. A dissolve keyed on zone_id collapses those five pieces back to two rows, aggregating the risk level with max so the attribute survives the round trip. One zone crossing two flood zones becomes three rows input: zoning zone_id A-12 4.0 km², one row zone_id A-13 2.5 km², one row overlay(zoning, floodplain, how="union") A-12 · risk High A-12 · risk Medium A-12 · risk NaN A-13 · risk Low A-13 · risk NaN 5 rows — one per boundary crossing, NaN where the layer did not reach dissolve(by="zone_id") A-12 · max High 3 pieces merged A-13 · max Low 2 pieces merged cuts collapses Area accounting, deduplication and attribute reconciliation all follow from this row count.
The overlay multiplies rows one-for-one with boundary crossings; dissolve is what puts the pieces back together without dropping out of the GeoDataFrame.
import pandas as pd

zoning_at_risk["risk_level"] = pd.Categorical(
    zoning_at_risk["risk_level"], categories=["Low", "Medium", "High", "Extreme"], ordered=True
)
reconciled = zoning_at_risk.dissolve(by="zone_id", aggfunc={"risk_level": "max"}).reset_index()
reconciled.to_parquet("zoning_flood_overlay.parquet", compression="zstd")

8. Run all five modes side by side before committing to one.

The fastest way to choose a mode is to look at what each one returns on your actual layers, because the row counts and area totals make the semantics concrete in a way the definitions do not. On a modest pair of layers this takes seconds and settles arguments permanently.

import geopandas as gpd

zoning_area = zoning.geometry.area.sum()
for mode in ("intersection", "union", "difference", "symmetric_difference", "identity"):
    out = gpd.overlay(zoning, floodplain, how=mode, keep_geom_type=True)
    print(f"{mode:<22} rows={len(out):>6,}  "
          f"area={out.geometry.area.sum():>15,.0f} m²  "
          f"cols={len(out.columns)}")

# intersection             rows= 1,842  area=     41,776,940 m²  cols=9
# union                    rows= 4,905  area=    271,118,203 m²  cols=9
# difference               rows= 2,463  area=    142,525,611 m²  cols=5
# symmetric_difference     rows= 3,063  area=    229,341,263 m²  cols=9
# identity                 rows= 4,305  area=    184,302,551 m²  cols=9

Three facts fall straight out of that table. identity returns exactly the left layer's area — it re-cuts zoning without adding or removing ground, which is why it is the right mode for anything that has to reconcile against the original totals. difference returns fewer columns because nothing from the right layer survives; it is the only mode that does not merge the two attribute sets. And union is the only mode whose area exceeds the left layer's, because it also carries the parts of the floodplain that lie outside any zone.

Note the runtime as well as the numbers. union and symmetric_difference are consistently the slowest, because on top of the intersection work they must also compute the leftovers of both inputs. If union is taking minutes and you only ever read the intersected rows, you asked for a more expensive mode than the question needed.

9. Resolve attribute collisions and multipart geometry before writing.

Two mechanical problems show up in the output frame. When both inputs carry a column of the same name, overlay suffixes them _1 and _2 — informative in a notebook, useless in a delivered file — so rename before the cut rather than after, while you still know which layer each column came from. And modes that keep leftovers routinely emit MultiPolygon rows where a single input feature was cut into disconnected pieces; if the consumer expects one geometry per row, explode them and keep the original index so the parts stay traceable.

import geopandas as gpd

# Rename before the cut so no column names collide in the first place
zoning_r = zoning.rename(columns={"name": "zone_name", "updated": "zone_updated"})
flood_r = floodplain.rename(columns={"name": "flood_name", "updated": "flood_updated"})

combined = gpd.overlay(zoning_r, flood_r, how="union", keep_geom_type=True)

# Split multipart rows into single polygons, keeping a trace of the source row
parts = combined.explode(index_parts=True).reset_index(names=["source_row", "part"])
print(parts["part"].max())        # 6  -> one feature was cut into seven pieces

# NaN marks "this layer did not reach here" — make that explicit for consumers
parts["in_floodplain"] = parts["risk_level"].notna()
parts["risk_level"] = parts["risk_level"].fillna("None")

The NaN handling matters more than it looks. In union and symmetric_difference output, a missing right-hand attribute is not missing data — it is a positive statement that the piece lies outside the right layer. Leaving it as NaN means every downstream groupby silently drops those rows, so a summary of flood risk by zone quietly omits all the land that is not at risk. Fill it with an explicit category, and keep the boolean flag alongside it.

Verification

Check that areas reconcile: intersection plus difference should equal the original zoning area to floating-point tolerance. A gap means invalid input or a CRS mismatch distorted the cut.

total_zoning = zoning.geometry.area.sum()
recombined = zoning_at_risk.geometry.area.sum() + zoning_safe.geometry.area.sum()

print(f"Original zoning area:   {total_zoning:,.0f} m²")     # 184,302,551 m²
print(f"Intersection + diff:    {recombined:,.0f} m²")        # 184,302,551 m²

assert abs(total_zoning - recombined) / total_zoning < 1e-6, "Area leak — check validity"
assert zoning_at_risk.geometry.is_valid.all()
assert set(zoning_at_risk.geom_type.unique()) <= {"Polygon", "MultiPolygon"}

Two further identities hold across the modes and catch different bugs. identity must return the left layer's area exactly, since it only re-cuts what was already there — a shortfall means features were dropped by keep_geom_type or by a validity filter. And symmetric_difference plus twice the intersection must equal the union, because the union counts the shared lens once while the other two account for it zero and twice respectively:

identity_out = gpd.overlay(zoning, floodplain, how="identity", keep_geom_type=True)
union_out = gpd.overlay(zoning, floodplain, how="union", keep_geom_type=True)
symdiff_out = gpd.overlay(zoning, floodplain, how="symmetric_difference", keep_geom_type=True)

a_identity = identity_out.geometry.area.sum()
a_union = union_out.geometry.area.sum()
a_symdiff = symdiff_out.geometry.area.sum()
a_inter = zoning_at_risk.geometry.area.sum()

assert abs(a_identity - total_zoning) / total_zoning < 1e-9, "identity lost area"
assert abs((a_symdiff + a_inter) - a_union) / a_union < 1e-9, "union does not reconcile"
print(f"identity {a_identity:,.0f} m² == zoning {total_zoning:,.0f} m²")
# identity 184,302,551 m² == zoning 184,302,551 m²

Run these on a small subset first — a couple of hundred features clipped to one district — so a failure points at a definite pair of layers rather than at forty minutes of wasted compute. If the intersection-plus-difference identity holds on the subset but fails on the full run, the culprit is almost always a handful of pathological features, and zoning[~zoning.geometry.is_valid] names them.

Edge Cases & Debugging

Frequently Asked Questions

Which mode should I use to answer "how much of X is inside Y"? intersection gives you the overlapping pieces to measure, but use identity if the answer has to reconcile against X's total. intersection drops every feature of X that never met Y, so the features with zero exposure disappear from the result entirely — and they belong in the denominator. Run identity, measure the tagged pieces, and the totals add up without a second pass.

Why is union so much slower than intersection on the same layers? Because it does strictly more work. Both modes compute the exact intersections, but union then has to derive the leftovers: for each feature, the part not consumed by any intersection, obtained by differencing it against everything it met. That second phase runs over both layers and dominates the runtime. If you never read the rows where one side is NaN, you paid for leftovers you discarded.

Do I need keep_geom_type=True if I am already passing polygons? Yes, because the output can contain non-polygons even when both inputs are clean. Two polygons that touch along an edge or at a corner intersect in a line or a point, and those degenerate pieces come through as geometry-collection members. keep_geom_type=True is the default in GeoPandas 1.0 and keeps only the polygonal parts, which is what any area calculation downstream assumes.

Should I use overlay or clip to restrict a layer to a study area? clip — it is the operation designed for a mask. It cuts the left layer to the outline of the right one, keeps only the left layer's attributes, and does not multiply rows against every mask feature. Use overlay(how="intersection") only when you need to know which right-hand feature each piece fell into and want that feature's attributes carried across.

Can I overlay more than two layers at once? Not in one call — chain the cuts, and expect the row count to multiply at each step. Three layers with a hundred features each can produce thousands of pieces, most of them tiny, so filter and dissolve between steps rather than at the end. Ordering matters for cost too: cut against the most selective layer first so later stages run on fewer features, and put the highest-vertex layer last.