Resolving Overlapping Polygons in Collaborative Editing

Concurrent polygon edits from multiple contributors inevitably produce intersection conflicts that must be eliminated before data reaches a shared repository. Part of the broader Geometry Overlap Resolution Techniques family, this page covers the specific pipeline for detecting and clipping away those overlaps using a priority-based approach in Python.

Concept & Context

When GIS contributors work on the same spatial extent in parallel — whether through feature branches, web editors, or disconnected field collection — they frequently produce polygons that intersect geometries already committed by other team members. Unlike attribute conflicts, polygon overlaps are invisible in a line-level diff and silently corrupt spatial joins, zonal statistics, and network routing.

The conflict resolution and team synchronization workflows that govern a healthy spatial repository must include an automated overlap-removal step. Without it, sliver polygons accumulate across merge cycles, precision grids diverge, and topology validators begin flagging errors that were never authored by any individual editor.

The core design decision is arbitration policy: which geometry wins when two polygons claim the same area? Three deterministic signals work in practice:

  • Timestamp priority — the most recent edit dominates, reflecting the assumption that newer data is more accurate.
  • Authority tier — edits from a designated authoritative source (survey data, cadastre import) override crowdsourced or derived features.
  • Confidence score — an attribute carrying a numeric quality rating drives the sort order, useful when temporal ordering is unavailable.

Whichever signal you choose, the key is that the rule is written down and executed the same way every time. For teams whose pipelines also handle sub-meter coordinate drift, pairing this step with automated patching for minor geometry shifts before the overlap check reduces false-positive detections caused by floating-point rounding.

Priority-based polygon overlap resolution pipeline Five sequential pipeline stages: Ingest & Validate, Spatial Index, Assign Priority, Clip Overlaps, Verify & Commit — connected by arrows Ingest & Validate Spatial Index (R-tree) Assign Priority Clip Overlaps Verify & Commit make_valid + set_precision O(n log n) candidate pairs timestamp / tier / score shapely .difference() assert zero intersections

Core Algorithmic Pipeline

The five-stage pipeline maps directly to the diagram above.

1. Validate and normalize geometries. Before any spatial predicate runs, every geometry must be topologically valid. shapely.make_valid repairs self-intersections and incorrect ring orientation. Immediately after, shapely.set_precision snaps all coordinates to a fixed grid (for example grid_size=1e-6 in decimal degrees, or 1e-3 in projected metres). This single step prevents the floating-point drift that creates micro-slivers after differencing.

2. Build a spatial index and collect candidate pairs. A brute-force O(n²) intersection test is impractical beyond a few thousand features. GeoDataFrame.sindex exposes an R-tree index; a single query_bulk call returns all bounding-box-overlapping pairs in O(n log n) time. Geometry-level intersection tests (intersects) run only on these candidates.

3. Assign priority scores. Sort the GeoDataFrame by the chosen signal in descending order so that the highest-priority feature occupies index 0. The sort must be stable and reproducible: if two features share the same timestamp, break ties on a secondary key (e.g. feature_id lexicographic order) to guarantee identical output across repeated runs.

4. Apply priority-based clipping. Iterate in priority order, maintaining a running union of all resolved geometries. For each incoming feature, subtract (difference) the resolved union from its geometry. If the result is non-empty after applying the precision grid, append it to the resolved list. This ensures no two output polygons overlap.

5. Remove slivers and verify topology. Call shapely.set_precision a second time on the resolved geometries to eliminate micro-vertices introduced by difference. Drop any geometry whose area falls below a minimum threshold (e.g. 0.01 m²). Assert that no pair in the output still intersects before returning.

Working Implementation

import geopandas as gpd
import shapely
import numpy as np
from typing import Optional

def resolve_polygon_overlaps(
    gdf: gpd.GeoDataFrame,
    priority_col: str = "edit_timestamp",
    secondary_col: str = "feature_id",
    grid_size: float = 1e-6,
    min_area: float = 1e-8,
) -> gpd.GeoDataFrame:
    """
    Resolve overlapping polygons using descending priority ordering.

    Higher-priority geometries claim contested area; lower-priority
    geometries are clipped to the residual space.  Slivers below
    `min_area` are discarded.

    Args:
        gdf:           Input GeoDataFrame; must have a geometry column.
        priority_col:  Column used as primary sort key (descending).
        secondary_col: Tiebreaker column (ascending) for stable output.
        grid_size:     Coordinate precision grid for shapely.set_precision.
        min_area:      Features smaller than this area are dropped as slivers.

    Returns:
        GeoDataFrame with non-overlapping polygons and original attributes.
    """
    if gdf.empty or len(gdf) < 2:
        return gdf.copy()

    gdf = gdf.copy()

    # --- Stage 1: validate and snap to precision grid ---
    gdf["geometry"] = gdf["geometry"].apply(
        lambda g: shapely.set_precision(shapely.make_valid(g), grid_size=grid_size)
        if g is not None and not g.is_empty
        else None
    )
    gdf = gdf.dropna(subset=["geometry"])

    # --- Stage 2: sort by priority (highest first) ---
    gdf = (
        gdf.sort_values(
            [priority_col, secondary_col],
            ascending=[False, True],
        )
        .reset_index(drop=True)
    )

    # --- Stage 3: build R-tree index for candidate detection ---
    sindex = gdf.sindex

    resolved_geoms: list[Optional[shapely.Geometry]] = [None] * len(gdf)
    resolved_union = shapely.GeometryCollection()  # running union of claimed area

    for i, row in gdf.iterrows():
        current = row["geometry"]

        # Find previously resolved features whose bbox intersects this one
        candidate_indices = list(sindex.query(current, predicate=None))
        # Only consider features with a lower index (higher priority, already resolved)
        prior_indices = [j for j in candidate_indices if j < i and resolved_geoms[j] is not None]

        if prior_indices:
            # Subtract all higher-priority geometries at once for efficiency
            prior_union = shapely.union_all(
                [resolved_geoms[j] for j in prior_indices]
            )
            if current.intersects(prior_union):
                current = current.difference(prior_union)

        # Snap and filter slivers
        if current is not None and not current.is_empty:
            current = shapely.set_precision(current, grid_size=grid_size)
            if current.area >= min_area:
                resolved_geoms[i] = current

    gdf["geometry"] = resolved_geoms
    return gdf.dropna(subset=["geometry"]).reset_index(drop=True)

The key improvement over a naive nested loop is the use of sindex.query to restrict difference calls to actual spatial candidates, and the batched shapely.union_all to compute a single subtraction per feature rather than a sequential chain.

Validation & Output Verification

After running resolve_polygon_overlaps, verify the output with three assertions before committing or passing data downstream:

def assert_no_overlaps(
    gdf: gpd.GeoDataFrame,
    tolerance: float = 1e-8,
) -> None:
    """Raise AssertionError if any polygon pair still intersects."""
    sindex = gdf.sindex
    geoms = gdf["geometry"].values

    for i, geom in enumerate(geoms):
        candidates = list(sindex.query(geom, predicate=None))
        for j in candidates:
            if j <= i:
                continue  # skip self and already-checked pairs
            if geom.intersects(geoms[j]):
                inter = geom.intersection(geoms[j])
                if inter.area > tolerance:
                    raise AssertionError(
                        f"Residual overlap between features {i} and {j}: "
                        f"area={inter.area:.2e}"
                    )

# Row-count sanity check: output should not gain rows
assert len(resolved) <= len(original), "Row count increased — check for geometry splits"

# Total area conservation: resolved area should be <= original (overlaps removed, not added)
area_before = original["geometry"].area.sum()
area_after  = resolved["geometry"].area.sum()
assert area_after <= area_before + 1e-4, f"Area grew unexpectedly: {area_after - area_before:.4f}"

For PostGIS-backed workflows, the equivalent verification query is:

-- Returns rows only if residual intersections exist
SELECT a.gid, b.gid, ST_Area(ST_Intersection(a.geom, b.geom)) AS overlap_area
FROM   resolved_layer a
JOIN   resolved_layer b ON a.gid < b.gid
WHERE  ST_Intersects(a.geom, b.geom)
  AND  ST_Area(ST_Intersection(a.geom, b.geom)) > 1e-8;

Zero rows returned confirms the pipeline succeeded. Embed this query as a database-level check inside your automated conflict detection in merge requests gate to block merges that still carry topological violations.

Failure Modes

  • Slivers persist after difference — root cause: set_precision not applied before differencing, so floating-point residuals remain. Fix: call shapely.set_precision(geom, grid_size=1e-6) on both operands before difference, not only after.

  • Row count increases after resolution — root cause: difference produced a MultiPolygon and the downstream explode step expanded it. Fix: call gdf.explode(index_parts=False) explicitly and re-run min_area filtering to drop small fragments.

  • Priority sort is non-deterministic across runs — root cause: edit_timestamp has duplicate values with no tiebreaker, so pandas sort is unstable. Fix: always specify secondary_col pointing to a unique identifier (feature_id, fid, or uuid).

  • shapely.union_all memory spike on large datasets — root cause: prior-feature unions are recomputed from scratch for each incoming feature. Fix: maintain an incremental union by accumulating resolved_union = resolved_union.union(current) after each step, and pass it directly rather than recomputing from prior_indices.


Back to Conflict Resolution & Team Synchronization Workflows