What Makes a Geometry Valid? The OGC Rules Explained

On this page

Problem statement

Every GIS practitioner meets validity through an error message rather than a definition:

shapely.errors.GEOSException: TopologyException: side location conflict at 5 5.
This can occur if the input geometry is invalid.

That is GEOS 3.13 intersecting a bow-tie with a square. And on a real layer — every OpenStreetMap polygon on the Isle of Wight:

import geopandas as gpd

gdf = gpd.read_file("data/raw/iow_polygons.gpkg")
print(gdf.geometry.is_valid.value_counts())
True     86061
False       19
Name: count, dtype: int64

So what does is_valid actually test? Not whether the file is readable, not whether the coordinates are sensible, and not whether the polygon looks right on screen — those 19 invalid polygons render perfectly. Validity is a precise statement about a geometry's topology, defined by the Open Geospatial Consortium's Simple Features specification, and every overlay operation in GEOS assumes it.

Understanding the rules pays off three times: you can read the error message, you can pick the right repair, and you can tell the difference between a geometry that is broken and one that is merely unusual.

Quick answer

A geometry is valid when its parts do not contradict each other:

  1. Lines are always valid; they may cross themselves (that makes them not simple, which is different)
  2. Polygon rings must be closed, have at least four coordinates, and must not self-intersect
  3. Holes must lie inside the shell, must not overlap each other, and may touch only at points
  4. The interior must be connected — a polygon may not be pinched into two pieces by its holes
  5. Multi-part geometries may not have overlapping parts; touching at a point is allowed
import geopandas as gpd
from shapely.geometry import Polygon
from shapely.validation import explain_validity

bowtie = Polygon([(0, 0), (10, 10), (10, 0), (0, 10)])
print(bowtie.is_valid)
print(explain_validity(bowtie))

good = Polygon([(0, 0), (10, 0), (10, 10), (0, 10)])
print(good.is_valid, good.is_simple)

try:
    bowtie.intersection(good)
except Exception as error:
    print(type(error).__name__ + ":", error)
False
Self-intersection[5 5]
True True
GEOSException: TopologyException: side location conflict at 5 5. This can occur if the input geometry is invalid.

explain_validity is the function to reach for: it names the rule that was broken and the coordinate where it happened, which is far more useful than a boolean.

The rules, in one picture

A valid polygon with a hole, a self-intersecting bow-tie, and a hole outside its shell. three cases you will meetvalidhole inside the shellself-intersectionthe bow-tiehole outside shellremoves nothingEvery rule prevents a question that would otherwise have no answer spatialworkflow.io
Valid, self-intersecting, and a hole that escapes its shell — the three cases you meet most.

The validity rules, one geometry type at a time

What each geometry type must satisfy to be valid. the rules by geometry typemustmayPointhave finite coordinatessit anywhere at allLineStringhave 2+ distinct points✓cross itselfPolygon ring!close, 4+ coords, no crosswind either wayHoleslie inside the shelltouch at single pointsMultiPolygon!not overlaptouch at single pointsA self-crossing line is valid; a self-crossing ring is not spatialworkflow.io
What each geometry type has to satisfy — and what it is allowed to get away with.

Points and lines: almost nothing can go wrong

A point is valid if its coordinates are finite. A LineString is valid if it has at least two distinct positions. That is the whole rule.

from shapely.geometry import Point, LineString

print(Point(1, 2).is_valid)
print(LineString([(0, 0), (10, 10), (10, 0), (0, 10)]).is_valid)    # crosses itself
print(LineString([(0, 0), (10, 10), (10, 0), (0, 10)]).is_simple)
print(explain_validity(LineString([(0, 0), (0, 0)])))                # no distinct points
True
True
False
Too few points in geometry component[0 0]

The distinction between valid and simple is the one people trip over. A line that crosses itself is perfectly valid — a road that loops back over a bridge is a real thing — but it is not simple. Simplicity is about self-intersection; validity is about structural coherence.

Polygon rings: closed, four points, no self-intersection

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

# a ring must close — Shapely closes it for you
square = Polygon([(0, 0), (10, 0), (10, 10), (0, 10)])
print(list(square.exterior.coords))     # the first point is repeated at the end

# fewer than four coordinates (three distinct + closure) is not a polygon — Shapely 2 refuses it
try:
    Polygon([(0, 0), (10, 0)])
except ValueError as error:
    print("ValueError:", error)

# a ring that crosses itself is the classic bow-tie; one that only touches itself fails too
print(explain_validity(Polygon([(0, 0), (10, 10), (10, 0), (0, 10)])))
print(explain_validity(Polygon([(0, 0), (10, 0), (10, 10), (5, 0), (0, 10)])))
[(0.0, 0.0), (10.0, 0.0), (10.0, 10.0), (0.0, 10.0), (0.0, 0.0)]
ValueError: A linearring requires at least 4 coordinates.
Self-intersection[5 5]
Ring Self-intersection[5 0]

Shapely 2 no longer lets you build the two-point polygon at all, so "too few points" now reaches you from files, where GDAL can read one, rather than from your own code.

A ring may not touch itself either: a ring that comes back to a point it has already visited, without crossing, is a "Ring Self-intersection", which GEOS rejects for polygons — the second message above.

Holes must be inside, and must not overlap

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

shell = [(0, 0), (100, 0), (100, 100), (0, 100)]

inside = Polygon(shell, [[(20, 20), (40, 20), (40, 40), (20, 40)]])
print(inside.is_valid)

outside = Polygon(shell, [[(120, 20), (140, 20), (140, 40), (120, 40)]])
print(explain_validity(outside))

overlapping = Polygon(shell, [
    [(20, 20), (60, 20), (60, 60), (20, 60)],
    [(40, 40), (80, 40), (80, 80), (40, 80)],
])
print(explain_validity(overlapping))

nested = Polygon(shell, [
    [(10, 10), (90, 10), (90, 90), (10, 90)],
    [(30, 30), (50, 30), (50, 50), (30, 50)],
])
print(explain_validity(nested))
True
Hole lies outside shell[120 20]
Self-intersection[40 60]
Holes are nested[30 30]

Overlapping holes are reported as a self-intersection; a hole wholly inside another hole gets its own message.

Holes touching the shell at a single point are legal. Holes sharing a segment with the shell, or with each other, are not — that would make the boundary ambiguous.

The interior must stay connected

This is the rule that surprises people. Two holes that touch the shell at opposite ends can pinch a polygon into two disconnected pieces, and that is invalid even though every individual ring is fine.

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

pinched = Polygon(
    [(0, 0), (100, 0), (100, 100), (0, 100)],
    [
        [(0, 50), (30, 40), (50, 50), (30, 60)],      # touches the left edge at (0, 50)
        [(50, 50), (70, 40), (100, 50), (70, 60)],    # touches the right edge, and the first hole at (50, 50)
    ],
)
print(explain_validity(pinched))
Interior is disconnected[50 50]

The holes meet the shell and each other only at single points, so every ring is fine; together they cut the square in two. An earlier version of this example let the holes run along the shell's edges, which GEOS reports as a plain self-intersection instead.

If you want two pieces, model them as a MultiPolygon — that is exactly what it is for.

The explain_validity message GEOS 3.13 gives for each kind of invalid geometry, built in Shapely 2.1. what explain_validity says, case by casegeometrymessagering crosses itself (bow-tie)Self-intersectionring touches itselfRing Self-intersectionhole outside the shellHole lies outside shelltwo holes overlapSelf-intersectiona hole inside a holeHoles are nestedholes cut the interior in twoInterior is disconnectedmultipolygon parts share an edgeSelf-intersectionNaN coordinateInvalid Coordinatea two-point polygon never gets this far: Shapely 2 raises ValueError on constructionEvery message ends with the coordinate where the rule broke spatialworkflow.io
Three different faults share the message "Self-intersection" — the coordinate tells them apart.

Multi-part geometries may touch, but not overlap

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

a = Polygon([(0, 0), (10, 0), (10, 10), (0, 10)])
b = Polygon([(10, 0), (20, 0), (20, 10), (10, 10)])    # shares an edge
c = Polygon([(5, 5), (15, 5), (15, 15), (5, 15)])      # genuinely overlaps

d = Polygon([(10, 10), (20, 10), (20, 20), (10, 20)])   # touches a at one corner

print(explain_validity(MultiPolygon([a, b])))          # shared edge
print(explain_validity(MultiPolygon([a, c])))          # overlapping parts
print(explain_validity(MultiPolygon([a, d])))          # a single shared point
Self-intersection[10 10]
Self-intersection[5 10]
Valid Geometry

Parts of a MultiPolygon may meet at a finite number of points. Sharing a boundary segment is invalid, which is why an incorrectly built multipart from adjacent parcels fails validity — those should be dissolved, or kept as separate features.

Reading explain_validity output

from shapely.validation import explain_validity
import geopandas as gpd

bad = gdf.loc[~gdf.geometry.is_valid].copy()
bad["reason"] = bad.geometry.map(explain_validity)
print(bad["reason"].str.split("[").str[0].value_counts())
reason
Self-intersection    19
Name: count, dtype: int64

On the Isle of Wight every one of the 19 is a self-intersection, each with the coordinate where the ring crosses — all in OpenStreetMap multipolygon relations, where outer and inner ways drawn separately can cross. Other layers mix the reasons; Example 1 builds one of each. The reason determines the repair. Self-intersections are what make_valid() handles cleanly; "too few points" is a degenerate geometry to drop; "interior is disconnected" usually means the data should have been a multipart in the first place.

Validity is not correctness

# perfectly valid, entirely wrong
from shapely.geometry import Polygon

parcel = Polygon([(-3.19, 55.95), (-3.18, 55.95), (-3.18, 55.96), (-3.19, 55.96)])
print(parcel.is_valid)          # even if this parcel belongs in Peru
True

A geometry can be valid and still be in the wrong place, the wrong CRS, the wrong size, or duplicated. Validity is a structural test, not a data-quality test — which is why it is one item on a cleaning checklist rather than the whole thing.

Validity on a real OpenStreetMap layer

The data used on this page

Every polygon in the Geofabrik OpenStreetMap extract for the Isle of Wight — 86,080 of them, 19 invalid as mapped — projected to British National Grid, as in the repair guide. Everything else on the page is built in code.

import os

import geopandas as gpd
import requests

os.makedirs("data/raw", exist_ok=True)
if not os.path.exists("data/raw/iow_polygons.gpkg"):
    pbf = "isle-of-wight-260101.osm.pbf"
    if not os.path.exists(pbf):
        with requests.get("https://download.geofabrik.de/europe/united-kingdom/england/"
                          "isle-of-wight-260101.osm.pbf", stream=True, timeout=300) as reply:
            with open(pbf, "wb") as out:
                for chunk in reply.iter_content(1 << 20):
                    out.write(chunk)
    osm = gpd.read_file(pbf, layer="multipolygons", columns=["osm_id", "osm_way_id", "name"])
    osm.to_crs(27700).to_file("data/raw/iow_polygons.gpkg")
print(len(gpd.read_file("data/raw/iow_polygons.gpkg")), "polygons")
86080 polygons

Example 1: build the invalid shapes yourself, to see them

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

CASES = {
    "bow-tie": Polygon([(0, 0), (10, 10), (10, 0), (0, 10)]),
    "hole outside": Polygon([(0, 0), (10, 0), (10, 10), (0, 10)],
                            [[(20, 20), (30, 20), (30, 30), (20, 30)]]),
    "overlapping holes": Polygon([(0, 0), (40, 0), (40, 40), (0, 40)],
                                 [[(5, 5), (25, 5), (25, 25), (5, 25)],
                                  [(15, 15), (35, 15), (35, 35), (15, 35)]]),
    "nested holes": Polygon([(0, 0), (40, 0), (40, 40), (0, 40)],
                            [[(5, 5), (35, 5), (35, 35), (5, 35)],
                             [(10, 10), (20, 10), (20, 20), (10, 20)]]),
    "ring touches itself": Polygon([(0, 0), (10, 0), (10, 10), (5, 0), (0, 10)]),
    "too few points": LineString([(0, 0), (0, 0)]),
    "NaN coordinate": Polygon([(0, 0), (float("nan"), 0), (1, 1), (0, 1)]),
    "valid with hole": Polygon([(0, 0), (40, 0), (40, 40), (0, 40)],
                               [[(10, 10), (20, 10), (20, 20), (10, 20)]]),
}

for name, geom in CASES.items():
    print(f"{name:20} valid={str(geom.is_valid):5}  {explain_validity(geom)}")
bow-tie              valid=False  Self-intersection[5 5]
hole outside         valid=False  Hole lies outside shell[20 20]
overlapping holes    valid=False  Self-intersection[15 25]
nested holes         valid=False  Holes are nested[10 10]
ring touches itself  valid=False  Ring Self-intersection[5 0]
too few points       valid=False  Too few points in geometry component[0 0]
NaN coordinate       valid=False  Invalid Coordinate[nan 0]
valid with hole      valid=True   Valid Geometry

Running this once builds the intuition faster than any amount of reading — and the shapes make good test fixtures.

Example 2: check validity as a pipeline gate

import geopandas as gpd

def assert_valid(gdf: gpd.GeoDataFrame, label: str, allow_pct: float = 0.0) -> None:
    invalid = (~gdf.geometry.is_valid).sum()
    pct = invalid / max(len(gdf), 1) * 100
    if pct > allow_pct:
        from shapely.validation import explain_validity
        sample = gdf.loc[~gdf.geometry.is_valid, "geometry"].head(3).map(explain_validity)
        raise ValueError(
            f"{label}: {invalid} invalid geometries ({pct:.2f}%), limit {allow_pct}%\n  "
            + "\n  ".join(sample)
        )
    print(f"{label}: {len(gdf)} features, {invalid} invalid ({pct:.2f}%)")

raw = gpd.read_file("data/raw/iow_polygons.gpkg")
clean = raw.assign(geometry=raw.make_valid(method="structure"))
assert_valid(clean, "after cleaning")
try:
    assert_valid(raw, "as downloaded")
except ValueError as error:
    print(str(error).splitlines()[0])
after cleaning: 86080 features, 0 invalid (0.00%)
as downloaded: 19 invalid geometries (0.02%), limit 0.0%

Example 3: what the repair actually changes

import geopandas as gpd
from shapely.geometry import Polygon
from shapely import make_valid

bowtie = Polygon([(0, 0), (10, 10), (10, 0), (0, 10)])
fixed = make_valid(bowtie)

print("input :", bowtie.geom_type, "area", bowtie.area)
print("output:", fixed.geom_type, "area", round(fixed.area, 2))
print("parts :", [g.geom_type for g in getattr(fixed, "geoms", [fixed])])
input : Polygon area 0.0
output: MultiPolygon area 50.0
parts : ['Polygon', 'Polygon']

Note the area: an invalid bow-tie reports zero area, because the two lobes have opposite orientation and cancel out. That is a good reason to check validity before trusting any area statistic.

What the rules protect against

The Simple Features specification defines a polygon not as "a closed shape" but as a region of the plane whose boundary is a set of rings, together with rules that keep the mapping from boundary to region unambiguous. Every rule exists to prevent a question that would have no answer.

Messages from explain_validity, what each means and what to do. reading explain_validityMessageWhat to doSelf-intersectionmake_valid() handles this cleanlyRing Self-intersectionmake_valid(), then check the typeNested holesRepair, then explode and filterHole lies outside shellRepair; often a digitising errorToo few points in geometryDegenerate — drop the rowInterior is disconnectedIt should be a MultiPolygonThe reason chooses the repair — a boolean does not spatialworkflow.io
Six validity messages, what each one means, and the repair it calls for.

Take the self-intersection. If a ring crosses itself, the plane is divided into lobes with conflicting orientations, and there is no consistent answer to "is this point inside?" — the standard point-in-polygon test gives different results depending on which rule you apply. Area becomes meaningless too: the signed areas of the lobes cancel, which is why a bow-tie reports an area of zero.

The hole rules follow the same logic. A hole outside its shell describes a region being removed from somewhere it was never part of. Two overlapping holes describe a region removed twice. A hole sharing an edge with the shell makes the boundary traversal ambiguous. In each case the geometry is not describing a region at all, so operations that assume it does are entitled to fail.

The connectivity rule — an interior may not be split in two by its holes — exists because Simple Features gives you MultiPolygon for exactly that case. Allowing a Polygon to be disconnected would create two representations of the same thing, and the specification is deliberately strict about having one.

This matters practically because GEOS, the library underneath Shapely, GeoPandas, PostGIS and QGIS, assumes validity in its overlay algorithms. The noding step that computes intersections between two geometries relies on each input having a coherent boundary. Feed it something contradictory and you may get a TopologyException, often at a coordinate far from where you would expect, because the failure is discovered mid-algorithm. "May" is the honest word: the bow-tie above fails every overlay, while the Isle of Wight's 19 invalid polygons went through dissolve, overlay and clip without an error in the repair guide. GEOS's overlay engine now survives much invalid input — which is why a missing error proves nothing about validity. That is why "repair first, overlay second" is the reliable order of operations — and why an early is_valid check is cheap insurance.

Valid, simple and correctly wound are different things

  • Valid ≠ simple: is_simple concerns self-intersection in lines. A self-crossing LineString is valid; a self-crossing polygon ring is not.
  • Ring orientation does not affect validity: Shapely accepts either winding order. Some formats (GeoJSON's right-hand rule, shapefiles' clockwise shells) care, so orientation can matter on export.
  • An invalid polygon can report area 0: The lobes cancel. Never compute statistics before checking validity.
  • Empty geometries are valid: Polygon().is_valid is True. Test is_empty separately.
  • NaN coordinates: A geometry containing NaN is invalid ("Invalid Coordinate") and unrepairable — make_valid raises rather than returning anything. Drop it.
  • GEOS versions differ slightly: Newer GEOS releases changed some edge-case messages and make_valid behaviour. Pin the version in a pipeline whose output must be stable.
  • Validity is per geometry, not per layer: Two valid parcels can still overlap each other. That is a topology-of-the-dataset question, which is_valid does not touch.

FAQ

What does is_valid actually check?

The OGC Simple Features rules: rings are closed and have enough points, rings do not self-intersect, holes lie inside the shell without overlapping, the interior is connected, and multi-part geometries do not overlap.

What is the difference between valid and simple?

Validity is about structural coherence and applies mainly to polygons. Simplicity is about self-intersection and applies mainly to lines. A self-crossing line is valid but not simple.

Why does my invalid polygon have an area of zero?

Because a self-intersecting ring produces lobes with opposite orientation, and their signed areas cancel. Repair the geometry before computing any area.

Can a geometry be valid and still be wrong?

Yes. Validity says nothing about position, CRS, scale or duplication. A perfectly valid parcel can sit in the wrong country — that is a data-quality question, not a topology one.

Why do overlays fail on invalid input?

GEOS computes intersections by noding the inputs' boundaries, which assumes each boundary is coherent. A contradictory boundary makes the noding step fail, usually reported as a TopologyException at a specific coordinate.

Are holes allowed to touch the shell?

At a single point, yes. Sharing a boundary segment is invalid, because it makes traversal of the boundary ambiguous.

Does ring orientation matter?

Not for validity in Shapely. It matters for some formats — GeoJSON specifies a right-hand rule, and shapefiles expect clockwise shells — so normalise orientation on export if a consumer is strict.