Fixing Invalid Polygons with Shapely

This page covers one technique from Geometry Cleanup & Topology Repair: turning an invalid room polygon into a valid one without silently changing what the room is. The distinction matters because shapely’s repair function always succeeds, and roughly two-thirds of the time what it returns is a diagnosis rather than a fix.

What OGC Validity Actually Requires

A polygon is valid under the OGC Simple Features rules when its rings are closed and simple (no self-intersection), its interior rings lie inside the exterior ring and do not overlap one another, and its interior is connected. Nothing in that definition mentions size, shape or plausibility — a 0.2 m² triangle in a stairwell is perfectly valid, and so is a single polygon covering the whole floor because a partition had a gap in it.

That is the first thing to internalise: validity is necessary and nowhere near sufficient. It is a precondition for spatial operations working at all, which is why it matters — an invalid polygon makes intersects, contains and buffer return wrong answers or raise TopologyException unpredictably, often several stages downstream from where the geometry was created.

What each OGC validity failure means and what make_valid does with it A grid of six OGC validity failures. A self-intersection is reported by GEOS as Self-intersection at a coordinate, and make_valid returns a MultiPolygon of separate lobes, which is not safe to use because picking one lobe discards area. A ring self-intersection returns a polygon with the ring split, which is not safe because a bow-tie was never a room. Duplicate rings are genuinely repaired into a deduplicated polygon and are safe. A hole lying outside its shell returns a MultiPolygon and is not safe, because the source geometry is wrong. Nested holes are merged and are safe. Too few points returns an empty geometry and is not safe, because the input was degenerate. Six failures, two real repairs, four disguised diagnoses Validity failure What GEOS reports make_valid returns Safe to use? Self-intersection Self-intersection at ... MultiPolygon of lobes △ no — pick a lobe and you lose area Ring self-intersection Ring Self-intersection Polygon, ring split △ no — a bow-tie was never a room Duplicate rings Duplicate Rings ● Polygon, deduplicated ● yes Hole outside shell Hole lies outside shell MultiPolygon △ no — geometry is wrong Nested holes Nested holes ● Polygon, merged ● yes Too few points Too few points in geometry △ empty geometry △ no — degenerate input Only two of six are repairs. The rest are diagnoses dressed as repairs.

make_valid always succeeds, which is the danger. It returns a valid geometry, not the geometry you meant — so the return type must be checked and a type change treated as a defect to report.

The GEOS messages in the first column are worth learning to read, because they name the cause precisely. Self-intersection means two edges cross. Ring Self-intersection means one ring crosses itself — the bow-tie case. Hole lies outside shell means an interior ring escaped its exterior, which in floor plans almost always means a courtyard polygon was assigned to the wrong room. Too few points in geometry component means a ring has fewer than four coordinates and was never a polygon at all.

Minimal Working Example

The complete repair is three lines. What makes it correct is the two checks around it.

import logging

from shapely.geometry import Polygon
from shapely.validation import explain_validity, make_valid

logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger(__name__)


def repair_room(poly: Polygon, *, area_tol: float = 0.01) -> Polygon | None:
    """Return a repaired room polygon, or None when the input is not repairable.

    A `None` return is information, not an error: it means the source geometry was
    wrong in a way no repair can resolve, and the caller must report it.
    """
    if poly.is_valid:
        return poly

    reason = explain_validity(poly)
    before = poly.area
    try:
        fixed = make_valid(poly)
    except Exception as exc:                       # GEOSException on degenerate rings
        logger.error("make_valid failed (%s): %s", reason, exc)
        return None

    if fixed.is_empty or fixed.geom_type != "Polygon":
        # A MultiPolygon means the ring genuinely self-intersected: the repair split
        # it into lobes. Choosing one would silently discard the rest of the room.
        logger.warning("unrepairable (%s): %s -> %s", reason, poly.geom_type, fixed.geom_type)
        return None

    drift = abs(fixed.area - before) / max(before, 1e-9)
    if drift > area_tol:
        logger.warning("repaired but area moved %.1f%% (%s)", drift * 100, reason)
    return fixed


bowtie = Polygon([(0, 0), (9, 0), (0, 7), (9, 7)])
print(repair_room(bowtie))          # -> None, with a warning naming the ring self-intersection
What make_valid does to a bow-tie polygon On the left, a single self-intersecting bow-tie ring with the crossing marked by a red cross. An arrow labelled make_valid leads to the right, where the same geometry has become a MultiPolygon of two triangles meeting at the former crossing point, drawn in two different colours to show they are now separate polygons. No area was added or removed, but one geometry became two, and code that takes the first of them silently keeps half the room. One ring in, two polygons out — and no exception anywhere input: one bow-tie ring make_valid output: MultiPolygon of two triangles

The repair is honest; the calling code usually is not. make_valid(bowtie).geoms[0] compiles, runs, and quietly halves a room — which is why the guard is on the returned type, not on whether an exception was raised.

The bow-tie is the case that catches people. make_valid returns a MultiPolygon of two triangles whose combined area equals the original’s — nothing was lost — but the geometry is now two rooms where the drawing showed one, and neither triangle is a room. The correct response is to report the source entity so the drawing can be fixed, not to pick a lobe.

Repair Decision Reference

Deciding what to do with the geometry make_valid handed back One decision on the returned geometry. A Polygon with the same area as the input is a genuine repair and is accepted. A Polygon whose area moved indicates a ring order change; it is accepted but the delta is logged. A MultiPolygon means the input was self-intersecting and the source geometry is wrong; it is reported rather than used, and the first sub-geometry must never be silently taken. An empty geometry or a GeometryCollection means degenerate input and fails the level. Branch on the returned type, never on the absence of an exception make_valid returned something. Now what? the returned type is the diagnosis Polygon, same area Accept a real repair Polygon, area moved Accept + count ring order changed log the delta MultiPolygon Report never pick .geoms[0] the source is wrong empty / GeometryCollection Reject degenerate input fail the level

Two branches accept, two do not. The area comparison is what separates the first two, and it costs one subtraction — cheaper than discovering months later that a floor's total area has been drifting.

Returned type Area check Action Reported as
Polygon within 1% accept
Polygon moved > 1% accept, log repaired_with_drift
MultiPolygon any reject the face self_intersecting
GeometryCollection any reject the face mixed_repair
empty any reject the face degenerate
exception raised reject the level repair_failed

The last row is deliberately harsher than the others. make_valid raising is rare and indicates something structurally broken — usually a ring with NaN coordinates, which comes from a unit conversion that divided by zero. One such geometry in a level means the level’s coordinates cannot be trusted, so failing the whole level is proportionate.

The area tolerance of 1% is generous on purpose. Genuine repairs — deduplicating a repeated ring, merging nested holes — change area by nothing or by rounding. A repair that moves area by more than a percent has changed the shape, and while the result may still be usable, it is worth a line in the report so that a portfolio-wide drift shows up as a trend rather than as a surprise.

Common Errors & Fixes

AttributeError: 'MultiPolygon' object has no attribute 'exterior'. The classic symptom of using make_valid’s output without checking its type. Some later stage — usually routing-graph construction, which walks room exteriors to find doors — receives a MultiPolygon where it expected a Polygon. The fix is not to add a type check at the crash site but to stop the MultiPolygon being produced upstream, by rejecting the face at repair time as shown above.

Total floor area silently shrinks between builds. Repairs that return a valid Polygon with different area accumulate. One room losing 3% is invisible; forty rooms losing 3% each is a floor that no longer matches its lease documents. Log the drift per repair and gate on the level’s total area against the previously published value — the delta gate described in the cleanup topic catches exactly this.

buffer(0) used as the repair. It was the standard trick before make_valid existed, and it still appears in a lot of code. It works by round-tripping the geometry through a zero-width buffer, which resolves self-intersections by dropping the smaller lobe rather than returning both. That makes it strictly worse than make_valid for this purpose: it produces a plausible single polygon and gives you no signal that anything was discarded. If you find buffer(0) in a cleanup path, replacing it with make_valid plus a type check will usually surface defects that were being swallowed for years.

# Do not do this: the smaller lobe of a bow-tie simply vanishes.
fixed = poly.buffer(0)

# Do this: the split is visible, and the caller decides.
fixed = make_valid(poly)
if fixed.geom_type != "Polygon":
    report(entity_handle, "self_intersecting")

Integration Point

Repair sits inside the classification pass of the cleanup pipeline, between polygonisation and the plausibility filters. Its input is the raw faces shapely.ops.polygonize produced from the noded segment graph; its output is the subset that both validates and survives the area and width floors described in Geometry Cleanup & Topology Repair.

Downstream, everything assumes validity. Wall & door detection measures perpendicular distances between faces and needs distance to be meaningful; routing-graph construction walks room exteriors to place door nodes and will raise on a MultiPolygon; and the schema validation at publish time rejects a FeatureCollection containing an invalid geometry outright. Repairing here, once, with a report, is what keeps all three of those stages simple.

Frequently Asked Questions

Is `make_valid` always better than `buffer(0)`?

For diagnosing floor-plan geometry, yes, and the reason is not accuracy but honesty. Both produce a valid result from an invalid input; buffer(0) resolves a self-intersection by keeping the larger lobe and discarding the rest, while make_valid returns everything and lets the type of the result tell you what happened. Since the whole point of the repair stage is to distinguish geometry that was nearly right from geometry that was wrong, a function that silently discards evidence is the wrong tool. The one place buffer(0) still has a use is deliberately dissolving a known-messy geometry where you have already decided the discard is acceptable.

Should invalid geometry fail the build?

The individual face should be rejected and counted; the build should fail on the aggregate. A handful of unrepairable faces per level is normal in a large portfolio and failing the build on each one means nothing ever publishes. What should fail the build is a level whose count of rejected faces jumps relative to its last published version, because that indicates something changed upstream — a new export setting, a different CAD version, a parser regression. Absolute thresholds age badly; deltas against the previous good build do not.

Why does the same polygon validate in QGIS but not in shapely?

Because they may be applying different validity models. GEOS — which shapely wraps — implements the strict OGC model, in which a polygon whose interior is pinched to a single point is invalid. Some tools apply the looser ESRI model, which permits it. Neither is wrong, but the strict model is the one that matters here, because it is what every downstream spatial predicate in the pipeline assumes. If a geometry validates in a desktop GIS and not in the pipeline, trust the pipeline: the operations that will run on it later are GEOS operations.

This page is a companion to Geometry Cleanup & Topology Repair, part of the Automated Floor Plan Parsing & Vectorization section.