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:
- 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)
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
The validity rules, one geometry type at a time
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.
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.
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_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 ("Invalid Coordinate") and unrepairable —
make_validraises rather than returning anything. 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
- Topological Coverage: What "Clean" Means for Adjacent Polygons
- 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.