Merging Overlapping Zone Edits with Shapely
Recover the version both editors branched from, compute each edit’s additions and removals against it, and refuse the merge where one edit’s addition meets the other’s removal — a union cannot express removal, so it silently restores the area an editor deliberately trimmed and produces a zone neither of them made.
This guide sits under Conflict Resolution Strategies, within Idempotency & Spatial Deduplication. It is the resolution step invoked when Implementing a Version Vector for Spatial Features classifies two edits as concurrent.
When to use this pattern
- Two concurrent edits to the same polygon have been detected, and discarding one is not acceptable.
- The common ancestor is available, which means the pipeline stored it deliberately.
- The zone is a coverage area, a service region or a delivery boundary — something where the union of two extensions is meaningful. It is not appropriate for parcels, where an overlap is a legal dispute.
Union loses the removals
Complete runnable implementation
from dataclasses import dataclass
from shapely.geometry import mapping, shape
from shapely.ops import unary_union
from shapely.validation import make_valid
# Slivers below this area are artefacts of coordinate precision rather than
# real disagreements, and must not trigger a refusal. Square degrees at
# EPSG:4326; ~1e-12 is under a square metre at mid-latitudes.
SLIVER_AREA = 1e-12
class MergeConflict(Exception):
"""Both edits made a deliberate, opposite decision about the same ground."""
def __init__(self, overlap_area: float, region) -> None:
super().__init__(f"contested area {overlap_area:.3e} sq deg")
self.region = region
@dataclass(frozen=True, slots=True)
class Delta:
added: object # shapely geometry: in the edit, not in the ancestor
removed: object # in the ancestor, not in the edit
def delta(ancestor, edited) -> Delta:
"""What one edit did, expressed as an addition and a removal."""
return Delta(added=edited.difference(ancestor),
removed=ancestor.difference(edited))
def three_way_merge(ancestor_geojson: dict, a_geojson: dict,
b_geojson: dict) -> dict:
"""Merge two concurrent zone edits against their common ancestor.
Raises MergeConflict when one edit added ground the other removed —
the geometric equivalent of two people editing the same line.
"""
ancestor = make_valid(shape(ancestor_geojson))
a, b = make_valid(shape(a_geojson)), make_valid(shape(b_geojson))
da, db = delta(ancestor, a), delta(ancestor, b)
# A added what B removed, or the reverse: both made a decision about the
# same ground and they disagree. Any automatic answer is a guess.
contested = unary_union([
da.added.intersection(db.removed),
db.added.intersection(da.removed),
])
if not contested.is_empty and contested.area > SLIVER_AREA:
raise MergeConflict(contested.area, mapping(contested))
merged = unary_union([ancestor, da.added, db.added])
merged = merged.difference(unary_union([da.removed, db.removed]))
# Boolean operations on real-world polygons routinely produce slivers and
# self-touching rings; repair before anything downstream validates it.
merged = make_valid(merged)
merged = merged.buffer(0)
if merged.is_empty:
raise MergeConflict(0.0, mapping(ancestor))
return mapping(merged)
The order matters: additions are applied before removals, so an area that one editor added and neither removed survives, while an area removed by either is gone regardless of who added it. Reversing the order lets an addition resurrect a removal, which is the union bug in a subtler form.
Parameter reference
| Name | Type | Spatial constraint | Default |
|---|---|---|---|
SLIVER_AREA |
float |
Square degrees; below a square metre at mid-latitudes | 1e-12 |
| Ancestor | GeoJSON | The version both editors loaded; must be stored deliberately | — |
make_valid |
call | On all three inputs — an invalid input makes every predicate unreliable | — |
buffer(0) |
call | After the booleans, to clean self-touching rings | — |
| Operation order | — | Additions then removals; reversing resurrects removed area | — |
MergeConflict.region |
GeoJSON | The contested ground, for a reviewer to look at | — |
Gotchas and spatial edge cases
-
A tolerance in square degrees is not a tolerance in square metres. At 60° north a degree of longitude is half its equatorial length, so the same
SLIVER_AREArepresents half the ground. If the threshold is contractual rather than cosmetic, reproject to a local equal-area CRS before measuring, as CRS Normalization Strategies describes. -
differencebetween polygons with nearly-coincident edges produces slivers. Two editors who both traced the same boundary by hand will differ by microdegrees, and every shared edge becomes a thread of area in the delta. Without the sliver threshold, every merge of hand-drawn geometry reports a conflict. -
make_validcan change the geometry type. An invalid polygon may come back as aGeometryCollectioncontaining polygons and stray lines, andunary_unionon that keeps the lines. Filter to polygonal parts before returning, or a zero-area line ends up in the stored zone and breaks the next area calculation. -
A merge that empties the zone is a conflict, not a result. If both editors removed most of the area, the intersection of what remains can be empty, and storing an empty geometry deletes the zone by arithmetic. Raising is the only safe response.
-
This merge is for coverage areas, not for parcels. Cadastral boundaries have legal meaning and an overlap between two of them is a dispute to be recorded, not a shape to be computed. Applying a geometric merge there produces a parcel that no register recognises.
-
The merged geometry needs a new version vector that dominates both inputs. Producing the shape without recording the merged lineage means the next edit rediscovers the same conflict — the failure the last test in the version-vector guide exists to catch.
Verification
import pytest
from shapely.geometry import Polygon, mapping, shape
ANCESTOR = mapping(Polygon([(0, 0), (0, 10), (10, 10), (10, 0)]))
def extended_north() -> dict:
return mapping(Polygon([(0, 0), (0, 14), (10, 14), (10, 0)]))
def trimmed_south() -> dict:
return mapping(Polygon([(0, 3), (0, 10), (10, 10), (10, 3)]))
def test_disjoint_changes_merge_faithfully():
"""North added, south removed — both intents survive."""
merged = shape(three_way_merge(ANCESTOR, extended_north(), trimmed_south()))
assert merged.bounds == (0.0, 3.0, 10.0, 14.0)
def test_union_would_have_restored_the_trimmed_area():
"""The bug this function exists to avoid, asserted explicitly."""
naive = shape(extended_north()).union(shape(trimmed_south()))
assert naive.bounds[1] == 0.0, "union keeps the removed southern strip"
merged = shape(three_way_merge(ANCESTOR, extended_north(), trimmed_south()))
assert merged.bounds[1] == 3.0
def test_contradictory_edits_refuse():
"""A added the eastern strip; B removed it."""
a = mapping(Polygon([(0, 0), (0, 10), (14, 10), (14, 0)]))
b = mapping(Polygon([(0, 0), (0, 10), (8, 10), (8, 0)]))
with pytest.raises(MergeConflict):
three_way_merge(ANCESTOR, a, b)
def test_hand_drawn_slivers_do_not_refuse():
"""Microdegree differences along a shared edge are not a conflict."""
a = mapping(Polygon([(0, 0), (0, 10.0000001), (10, 10), (10, 0)]))
b = mapping(Polygon([(0, 0), (0, 9.9999999), (10, 10), (10, 0)]))
three_way_merge(ANCESTOR, a, b) # must not raise
The second test is worth keeping even though it asserts something about code that is not being used: it documents the failure mode in executable form, so anyone tempted to replace the function with a one-line union sees immediately what that costs.
Related
- Conflict Resolution Strategies — the topic this guide belongs to
- Implementing a Version Vector for Spatial Features — how a conflict gets detected and routed here in the first place
- Repairing Self-Intersecting Polygons Without Losing Area — what
make_validis doing, and what it costs - Spatial Overlap Deduplication — deciding whether two geometries are the same thing, rather than how to combine them