Repairing Self-Intersecting Polygons Without Losing Area

Prefer make_valid, which splits a bowtie into both lobes, over buffer(0), which typically returns one and discards the other — then measure the relative area change on every repair and reject the ones that exceed a tolerance, because both functions return a valid geometry and neither tells you what it threw away.

This guide sits under Geometry Validation Pipelines, within Spatial Payload Routing & Parsing. It covers the repair step that topic’s pipeline invokes once a geometry has been found invalid.

When to use this pattern

  • Producers emit invalid geometry occasionally, which for anything hand-digitised or machine-simplified they do.
  • Downstream operations need validity — spatial joins, area calculations, tile generation all misbehave on invalid input.
  • Silently changing a feature’s extent is unacceptable, which for anything measured it is.

The two repairs disagree, and only one says so

Same input, valid output, half the area A bowtie polygon is drawn: a quadrilateral whose ring crosses itself in the middle, producing two triangular lobes of roughly equal area. It is invalid, because a polygon ring may not cross itself. make_valid resolves it by noding the ring at the crossing point and returning both lobes as a multipolygon, so the repaired geometry has the same total area as the two lobes the coordinates describe. buffer with a distance of zero resolves the same shape through an even-odd interpretation of the ring and returns a single polygon containing one lobe, discarding the other. Both results are valid. Neither function raises, warns or returns any indication that a decision was made, so a pipeline that chose buffer zero because it is shorter to type loses half of every bowtie it repairs. The loss shows up much later as an area total that is too small, or as a coverage gap in a region where one producer's digitising tool happens to produce bowties. a bowtie: the ring crosses itself, so the polygon is invalid input crossing two lobes, roughly equal area make_valid MultiPolygon — both lobes kept total area unchanged buffer(0) discarded Polygon — one lobe about half the area, silently both are valid neither raises neither warns neither reports what it decided so measure it The loss surfaces later as an area total that is too small, or a coverage gap in whichever region uses the digitising tool that produces bowties.
Figure 1. Choosing buffer(0) because it is shorter to type is a decision about half the geometry, made without knowing it was a decision.

Complete runnable implementation

python
from dataclasses import dataclass

from shapely.geometry import GeometryCollection, MultiPolygon, mapping, shape
from shapely.ops import unary_union
from shapely.validation import explain_validity, make_valid

# Relative, because one square metre is nothing on a country and most of a
# parking bay. The absolute floor stops a near-zero-area sliver dividing by
# something close to nothing.
MAX_RELATIVE_AREA_LOSS = 0.001          # 0.1%
ABSOLUTE_AREA_FLOOR = 1e-14             # square degrees


class UnrepairableGeometry(Exception):
    """The repair changed the feature more than a repair is allowed to."""

    def __init__(self, reason: str, relative_loss: float) -> None:
        super().__init__(f"{reason} (relative area change {relative_loss:.4%})")
        self.relative_loss = relative_loss


@dataclass(frozen=True, slots=True)
class Repair:
    geometry: dict
    was_valid: bool
    reason: str | None
    relative_area_change: float


def _polygonal(geom):
    """make_valid can return a GeometryCollection with stray lines in it.

    Keeping those produces a geometry whose area is right and whose type
    breaks the next operation that assumes polygons.
    """
    if isinstance(geom, GeometryCollection):
        parts = [g for g in geom.geoms if g.geom_type in ("Polygon", "MultiPolygon")]
        return unary_union(parts) if parts else geom
    return geom


def repair(geometry: dict,
           max_loss: float = MAX_RELATIVE_AREA_LOSS) -> Repair:
    """Repair an invalid polygon, refusing repairs that change its extent.

    A repair is an assertion about what the producer meant. Making it
    silently means nobody ever fixes the producer.
    """
    original = shape(geometry)

    if original.is_valid:
        return Repair(geometry, True, None, 0.0)

    reason = explain_validity(original)
    # make_valid, NOT buffer(0): the latter resolves a bowtie by even-odd
    # interpretation and returns one lobe.
    repaired = _polygonal(make_valid(original))

    before = original.area
    after = repaired.area
    denominator = max(before, ABSOLUTE_AREA_FLOOR)
    change = abs(after - before) / denominator

    if change > max_loss:
        raise UnrepairableGeometry(reason, change)

    if repaired.is_empty:
        raise UnrepairableGeometry(f"{reason}; repair produced an empty geometry", 1.0)

    return Repair(mapping(repaired), False, reason, change)

Note that original.area on an invalid polygon is itself unreliable — a self-intersecting ring’s area is computed by the shoelace formula, which subtracts the lobe wound the other way. That is precisely why the comparison is worth making: a bowtie whose lobes are equal has an original area near zero, so almost any repair exceeds the tolerance and the geometry is rejected rather than quietly reinterpreted.

Not every invalidity deserves the same answer Three kinds of invalid polygon are separated by what caused them. A ring that self-touches by a micrometre is an artefact of coordinate precision — the producer meant a simple polygon and floating-point rounding produced a degenerate touch. Repairing it changes the area by a negligible fraction and is uncontroversial; automating it is correct. A duplicate consecutive vertex or a ring not explicitly closed is a serialisation defect rather than a geometric one, again safe to repair automatically and worth counting so the producer can be told. A genuine bowtie is different in kind: the coordinates describe two lobes and there is no way to know whether the producer meant both, one, or something else entirely. Repairing it automatically asserts an answer to that question, and asserting it silently means the producer is never fixed and the assertion is never reviewed. The area-change tolerance is what separates the first two classes from the third without needing to classify them by name, because the first two barely move the area and the third moves it a great deal. what caused the invalidity decides whether repairing it is safe micrometre self-touch a coordinate-precision artefact the producer meant a simple polygon area change: negligible repair automatically no decision is being made duplicate vertex · unclosed ring a serialisation defect, not a geometric one area change: none repair, and count it the producer can be told, and fixed genuine bowtie the coordinates describe two lobes did the producer mean both? one? area change: large reject — do not assert an answer silently asserting it means nobody reviews it The area tolerance separates these three without needing to classify them by name: the first two barely move the area and the third moves it a great deal. One threshold, and it is expressed in the units the feature is actually measured in.
Figure 2. The tolerance does the classification for you, which matters because explain_validity returns a message rather than a category.

Parameter reference

Name Type Spatial constraint Default
MAX_RELATIVE_AREA_LOSS float Proportion, not an absolute area — a country and a parking bay differ 0.001
ABSOLUTE_AREA_FLOOR float Stops a near-zero-area sliver dividing by nothing 1e-14
Repair function make_valid; buffer(0) discards bowtie lobes make_valid
_polygonal call Strips stray lines from a returned GeometryCollection
explain_validity str Recorded on the event, so producers can be told what they emit
Area CRS Equal-area projection when the tolerance must mean square metres

Gotchas and spatial edge cases

  1. make_valid can change the geometry type. A repaired polygon may come back as a MultiPolygon, or as a GeometryCollection containing polygons plus dangling lines from a degenerate spike. Code downstream that assumes Polygon breaks on the first repaired feature, and the stray lines have zero area so the tolerance check does not notice them — hence the explicit filter.

  2. The area of an invalid polygon is not what it looks like. The shoelace formula subtracts area enclosed with the opposite winding, so a symmetric bowtie has an area near zero. Treating that as the “before” figure is correct here — it makes the tolerance reject the shape — but it means the reported relative change is not a physical quantity and should not be graphed as one.

  3. Areas in EPSG:4326 are in square degrees. A relative tolerance is unaffected by that, because both sides of the ratio use the same units, which is another reason to express it relatively. If the tolerance ever needs to be absolute, reproject first as CRS Normalization Strategies describes.

  4. Ring winding order is not a validity question. RFC 7946 specifies counter-clockwise exterior rings, but a clockwise ring is still a valid polygon to Shapely — is_valid returns true and nothing repairs it. Winding must be normalised separately, and it matters because some consumers interpret a reversed exterior ring as a hole.

  5. Repair before any spatial predicate, not after. intersects, contains and intersection on an invalid geometry return results that are wrong rather than erroneous, so a validation stage placed after routing has already let the bad answers through.

  6. Count repairs by producer and alert on the rate. A repair is a message about the source system, and the only way anybody acts on it is if the rate is visible per producer — see Tracking Geometry Validation Failure Rate with Prometheus.

The repair can return something that is not a polygon A polygon with a degenerate spike — a vertex path that runs out and back along the same line — is repaired. make_valid resolves the ring correctly but the spike has no area, so it cannot be part of a polygon; the result is a GeometryCollection containing the repaired polygon and a LineString for the spike. The area is exactly right, so the tolerance check passes and the repair is accepted. Downstream, anything that assumes a Polygon or MultiPolygon now meets a collection: a spatial join may silently skip it, a tile renderer may draw the line, and a later union carries the line forward into the stored geometry where it will confuse the next area calculation. Because the stray part has zero area, no area-based check can ever detect it. Filtering the collection to its polygonal parts before returning is the only step that catches it, and it has to be explicit because both the repair and the tolerance check consider the result correct. a polygon with a degenerate spike spike make_valid GeometryCollection Polygon — the repaired shape LineString — the spike, zero area area is exactly right, so the tolerance passes what it breaks downstream a spatial join may silently skip a collection a renderer may draw the line a later union carries it into the stored geometry The stray part has zero area, so no area-based check can detect it. Filtering to the polygonal parts is the only step that catches it — and it has to be explicit, because both the repair and the tolerance check consider this result correct. A repair that changes the geometry TYPE is a different kind of change from one that changes its extent, and needs its own guard.
Figure 3. The tolerance check guards extent, not type. Both need guarding, and only one of them is obvious from the failure it prevents.

Verification

python
import pytest
from shapely.geometry import Polygon, mapping, shape

BOWTIE = mapping(Polygon([(0, 0), (10, 10), (0, 10), (10, 0), (0, 0)]))
NICKED = mapping(Polygon([(0, 0), (0, 10), (10, 10), (10, 0),
                          (5, 1e-9), (0, 0)]))       # micrometre self-touch


def test_bowtie_is_rejected_not_halved():
    """The failure buffer(0) produces silently."""
    with pytest.raises(UnrepairableGeometry):
        repair(BOWTIE)


def test_buffer_zero_would_have_lost_a_lobe():
    """Documented in executable form, so nobody 'simplifies' the repair."""
    both = shape(mapping(shape(BOWTIE).buffer(0)))
    from shapely.validation import make_valid
    kept = make_valid(shape(BOWTIE))
    assert kept.area > both.area * 1.5


def test_precision_artefact_is_repaired_quietly():
    result = repair(NICKED)
    assert result.was_valid is False
    assert result.relative_area_change < 1e-6
    assert shape(result.geometry).is_valid


def test_valid_geometry_passes_through_unchanged():
    square = mapping(Polygon([(0, 0), (0, 1), (1, 1), (1, 0)]))
    result = repair(square)
    assert result.was_valid and result.geometry == square


def test_repair_never_returns_a_geometry_collection():
    """Stray lines have zero area, so the tolerance does not catch them."""
    spiked = mapping(Polygon([(0, 0), (0, 10), (5, 10), (5, 20),
                              (5, 10), (10, 10), (10, 0)]))
    result = repair(spiked, max_loss=1.0)
    assert shape(result.geometry).geom_type in ("Polygon", "MultiPolygon")

The second test asserts something about code the pipeline deliberately does not use. It is there so that the next person who replaces make_valid with the shorter buffer(0) sees the consequence in a failing test rather than in an area report six months later.