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:
- Lines are always valid; they may cross themselves (that makes them not simple, which is different)
- Polygon rings must be closed, have at least four coordinates, and must not self-intersect
- Holes must lie inside the shell, must not overlap each other, and may touch only at points
- The interior must be connected β a polygon may not be pinched into two pieces by its holes
- 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
Step-by-step solution
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.
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_simpleconcerns self-intersection in lines. A self-crossingLineStringis 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_validisTrue. Testis_emptyseparately. - 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_validbehaviour. 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_validdoes not touch.
Internal links
- How to Fix Invalid Geometries in Python (GeoPandas)
- buffer(0) Does Not Fix My Invalid Geometry: What to Do Instead
- Shapely TopologyException: Found Non-Noded Intersection (How to Fix)
- Shapely Basics: Working with Geometry Objects in Python
- Topology in GIS Explained: Slivers, Gaps and Shared Boundaries
- The Python GIS Data Cleaning Checklist: From Raw Download to Analysis-Ready
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.