What Makes a Geometry Valid? The OGC Rules Explained

Problem statement

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

shapely.errors.GEOSException: TopologyException: Input geom 0 is invalid:
Self-intersection at 325104.2 673992.7
>>> gdf.geometry.is_valid.value_counts()
True     3608
False    1204

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 β€” 1,204 invalid parcels can 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)                # False
print(explain_validity(bowtie))       # Self-intersection[5 5]

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

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

Three small panels: a valid polygon with a hole, a self-intersecting bow-tie, and a hole crossing the shell.
Valid, self-intersecting, and a hole that escapes its shell β€” the three cases you meet most.

Step-by-step solution

Grid of validity rules by geometry type: points, lines, polygons, multi-parts and collections.
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)                                    # True
print(LineString([(0, 0), (10, 10), (10, 0), (0, 10)]).is_valid)  # True β€” crosses itself
print(LineString([(0, 0), (10, 10), (10, 0), (0, 10)]).is_simple) # False
print(LineString([(0, 0), (0, 0)]).is_valid)                   # False β€” no distinct points

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
print(explain_validity(Polygon([(0, 0), (10, 0)])))       # too few points

# a ring that crosses itself is the classic bow-tie
print(explain_validity(Polygon([(0, 0), (10, 10), (10, 0), (0, 10)])))
# Self-intersection[5 5]

A ring may touch itself at a single point β€” a figure-of-eight shape pinched at the middle is invalid, but a ring that comes back to touch itself without crossing is a "ring self-intersection", which GEOS also rejects for polygons.

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)                                     # True

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

overlapping = Polygon(shell, [
    [(20, 20), (60, 20), (60, 60), (20, 60)],
    [(40, 40), (80, 40), (80, 80), (40, 80)],
])
print(explain_validity(overlapping))                       # Self-intersection

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)],
    [
        [(10, 0), (50, 50), (90, 0)],        # touches the bottom edge
        [(10, 100), (50, 50), (90, 100)],    # touches the top edge, meeting at (50, 50)
    ],
)
print(explain_validity(pinched))             # Interior is disconnected

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

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

print(explain_validity(MultiPolygon([a, b])))          # Self-intersection β€” shared edge
print(explain_validity(MultiPolygon([a, c])))          # Self-intersection β€” overlapping parts

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())
Self-intersection             842
Ring Self-intersection        210
Nested holes                   87
Hole lies outside shell        41
Too few points in geometry     24
Interior is disconnected        9

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)          # True β€” even if this parcel belongs in Peru

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.

Code examples

Example 1: a validity report for a whole layer

import geopandas as gpd
from shapely.validation import explain_validity

def validity_report(gdf: gpd.GeoDataFrame) -> dict:
    geom = gdf.geometry
    invalid = ~geom.is_valid
    report = {
        "features": len(gdf),
        "null": int(geom.isna().sum()),
        "empty": int(geom.is_empty.sum()),
        "invalid": int(invalid.sum()),
        "not_simple": int((~geom.is_simple).sum()),
    }
    if invalid.any():
        reasons = geom[invalid].map(explain_validity).str.split("[").str[0]
        report["reasons"] = reasons.value_counts().to_dict()
        report["examples"] = geom[invalid].map(explain_validity).head(3).tolist()
    return report

for key, value in validity_report(gpd.read_file("data/raw/parcels.gpkg")).items():
    print(f"{key:12} {value}")

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

from shapely.geometry import 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)]]),
    "nested 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)]]),
    "too few points": Polygon([(0, 0), (1, 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:16} valid={str(geom.is_valid):5}  {explain_validity(geom)}")

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

Example 3: 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}%)")

assert_valid(gpd.read_file("data/clean/parcels.gpkg"), "after cleaning")

Example 4: 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)          # Polygon, area 0.0
print("output:", fixed.geom_type, "area", round(fixed.area, 2))  # MultiPolygon, area 50.0
print("parts :", [g.geom_type for g in getattr(fixed, "geoms", [fixed])])

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.

Explanation

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.

Triage table mapping explain_validity messages to what they mean and what to do.
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 get a TopologyException, often at a coordinate far from where you would expect, because the failure is discovered mid-algorithm. That is why "repair first, overlay second" is the reliable order of operations β€” and why an early is_valid check is cheap insurance.

Edge cases or notes

  • 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 and unrepairable β€” 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.