Validating Ring Orientation and Winding Order
Winding order is the direction a polygon’s vertices are listed in, and it is the quietest interoperability defect in vector data. A reversed exterior ring is topologically identical to a correct one — same shape, same area, valid under the OGC rules — right up until it reaches a renderer that fills by winding rule, a vector tile encoder, or a GeoJSON consumer that follows RFC 7946 literally. Then the polygon appears inverted, holes fill in, or a point-in-polygon test returns the opposite answer. This guide detects the problem per ring, explains which convention applies where, and normalises orientation as part of the validity work described in Geometry Validity Checks for Vector Data.
Prerequisites
- shapely 2.0+ and geopandas 0.14+; PostGIS 3.2+ for the SQL variants.
- A declared output convention per format. This is the whole prerequisite: without knowing what the consumer expects, orientation cannot be validated, only observed.
- Valid input geometry. Orientation checks assume the rings are well formed; run the validity checks first, because the ring structure of an invalid polygon is not meaningful.
Step-by-Step Procedure
Step 1 — Write down the convention per output
| Output format | Exterior ring | Interior rings | Enforced by the format? |
|---|---|---|---|
| GeoJSON (RFC 7946) | counter-clockwise | clockwise | No — a “should”, widely ignored |
| Shapefile | clockwise | counter-clockwise | Yes, in practice by most readers |
| GeoPackage / WKB | either | either | No |
| PostGIS storage | either | either | No |
| Vector tiles (MVT) | clockwise (screen space) | counter-clockwise | Yes, by the encoder |
| OGC simple features | insignificant | insignificant | No |
The important row is the last one: orientation has no bearing on validity, which is exactly why it survives every check in a pipeline and then breaks a client.
Step 2 — Measure the signed area of every ring
# orientation/check.py
import geopandas as gpd
from shapely.geometry import Polygon, MultiPolygon
def ring_orientations(geom) -> list[dict]:
"""Direction of every ring in a polygonal geometry."""
polys = geom.geoms if isinstance(geom, MultiPolygon) else [geom]
out = []
for part_index, poly in enumerate(polys):
out.append({"part": part_index, "ring": "exterior", "index": 0,
"ccw": poly.exterior.is_ccw,
"signed_area": _signed_area(poly.exterior.coords)})
for i, hole in enumerate(poly.interiors):
out.append({"part": part_index, "ring": "interior", "index": i,
"ccw": hole.is_ccw, "signed_area": _signed_area(hole.coords)})
return out
def _signed_area(coords) -> float:
"""Shoelace formula: positive is counter-clockwise, negative is clockwise."""
pts = list(coords)
return 0.5 * sum((x1 * y2 - x2 * y1)
for (x1, y1), (x2, y2) in zip(pts, pts[1:]))
def check_layer(gdf: gpd.GeoDataFrame, exterior_ccw: bool = True) -> list[dict]:
"""One finding per wrongly wound ring — RFC 7946 defaults."""
findings = []
for idx, geom in gdf.geometry.items():
if geom is None or geom.is_empty:
continue
for ring in ring_orientations(geom):
want = exterior_ccw if ring["ring"] == "exterior" else not exterior_ccw
if ring["ccw"] != want:
findings.append({
"feature_id": str(idx),
"rule": "GEOM_WIND_001",
"severity": "warning",
"message": (f"part {ring['part']} {ring['ring']} ring {ring['index']} "
f"is {'CCW' if ring['ccw'] else 'CW'}, expected "
f"{'CCW' if want else 'CW'}"),
})
return findings
Verification: run against a layer you know is RFC 7946-compliant and expect zero findings; reverse one exterior ring and confirm exactly one finding, naming the part and ring index.
Step 3 — Check it in the database too
-- PostGIS: find features whose exterior ring is not counter-clockwise.
SELECT parcel_id,
ST_IsPolygonCCW(geom) AS all_rings_follow_ogc_ccw,
ST_NumInteriorRings(geom) AS holes
FROM parcels
WHERE NOT ST_IsPolygonCCW(geom);
-- Normalise on the way out to GeoJSON, without touching the stored geometry.
SELECT parcel_id,
ST_AsGeoJSON(ST_ForcePolygonCCW(geom)) AS geojson
FROM parcels;
Verification: ST_IsPolygonCCW evaluates the whole polygon — exterior counter-clockwise and interiors clockwise — so a false result does not say which ring is wrong. Use it as a fast screen and the Python check for the detail.
Step 4 — Normalise, reversing only what is wrong
# orientation/normalise.py
from shapely.geometry import Polygon, MultiPolygon
from shapely.geometry.polygon import orient
def to_rfc7946(geom):
"""Counter-clockwise exteriors, clockwise holes. Coordinates are unchanged."""
if isinstance(geom, MultiPolygon):
return MultiPolygon([orient(p, sign=1.0) for p in geom.geoms])
if isinstance(geom, Polygon):
return orient(geom, sign=1.0)
return geom
def to_shapefile_convention(geom):
"""Clockwise exteriors, counter-clockwise holes."""
if isinstance(geom, MultiPolygon):
return MultiPolygon([orient(p, sign=-1.0) for p in geom.geoms])
if isinstance(geom, Polygon):
return orient(geom, sign=-1.0)
return geom
def assert_unchanged(before, after, tol: float = 1e-9) -> None:
"""Reversal must not move anything — this is the guard that makes it safe."""
assert abs(before.area - after.area) < tol, "area changed during reorientation"
assert before.equals(after), "geometry changed during reorientation"
Verification: the assertions in assert_unchanged should never fire. They are cheap and they turn “reversal is safe in theory” into a checked property, which matters when the normalisation runs unattended over a whole layer.
Step 5 — Re-check after writing
# orientation/roundtrip.py
import geopandas as gpd
def orientation_survives_write(gdf: gpd.GeoDataFrame, path: str, driver: str) -> dict:
"""Drivers rewrite winding order silently — verify after serialisation, not before."""
normalised = gdf.assign(geometry=gdf.geometry.map(to_rfc7946))
normalised.to_file(path, driver=driver)
reread = gpd.read_file(path)
bad = check_layer(reread, exterior_ccw=True)
return {"driver": driver, "features": len(reread), "wrong_after_write": len(bad)}
Verification: run it for GeoJSON and for shapefile. The shapefile round trip will report every exterior ring as “wrong” against the RFC 7946 expectation — correctly, because the shapefile driver rewrites to the clockwise convention on write. That result is the point of the test: orientation is a property of the file, not of the in-memory object, and it must be asserted at the boundary.
Interpreting Results
| Result | Meaning | Action |
|---|---|---|
| All exteriors clockwise in GeoJSON | Data came from a shapefile with no reorientation | Normalise before publishing |
| Mixed orientation within one layer | Features from multiple sources merged without normalisation | Normalise the whole layer |
| Holes wound the same way as exteriors | Producer ignored the hole convention entirely | Normalise; check the renderer for filled holes |
ST_IsPolygonCCW false, Python check clean |
Interior rings are the problem, not exteriors | Read the per-ring detail |
| Orientation correct in memory, wrong in the file | The driver rewrote it | Assert after write, and set the convention per output |
| Validity passes, rendering looks inverted | Classic winding-order symptom | Check orientation before investigating anything else |
Because orientation never fails a validity check, its findings are usually warning severity — with one exception. If the consuming system is a vector tile pipeline or anything that fills by winding rule, a reversed ring produces visibly wrong output, and for that pipeline it is a blocker. Severity here is a property of the consumer, which is the argument made throughout Categorizing and Prioritizing Spatial Errors.
Gotchas & Edge Cases
GEOS does not care, so nothing upstream will tell you. ST_IsValid, make_valid and every predicate treat orientation as insignificant. The check has to be explicit or it does not happen.
orient() reorients the whole polygon, including holes. That is usually what you want, but it means the function is not a per-ring fix. Where only one hole is wrong, reorienting the polygon is still correct and still safe.
Antimeridian-crossing polygons confuse signed area. A ring spanning ±180 degrees in geographic coordinates produces a signed area whose sign depends on how the crossing is represented. Split such polygons at the antimeridian before checking orientation, or work in a projected system.
Degenerate rings have zero signed area and no direction. A collapsed ring returns is_ccw as an arbitrary value. Filter zero-area rings out first — they are a validity defect and should already have been caught.
Multipolygon parts can disagree with each other. Checking only the first part is a common shortcut and misses the case where a merge introduced one reversed part. Iterate every part, as the code above does.
Some tile encoders reorient for you and some do not. Do not rely on downstream normalisation; assert the convention at the boundary you control.
When to Escalate
- A supplier delivering GeoJSON with consistently reversed rings is producing non-conformant output. Normalising locally works, but the supplier should be told, because the same file will break other consumers.
- Orientation-dependent rendering defects that survive normalisation point at the encoder rather than at the data — check whether it is applying its own convention on top of yours.
- Mixed conventions inside a single authoritative layer indicate an ingestion path with no normalisation step. Fix the ingestion, not the layer, or the problem returns with the next load.
- A consumer that requires clockwise GeoJSON is reading RFC 7946 incorrectly, but if it is a system you cannot change, record the deviation explicitly in the product metadata rather than quietly producing non-conformant files.
Frequently Asked Questions
Does winding order affect whether a geometry is valid?
Not under the OGC simple features rules — GEOS treats orientation as insignificant, and a reversed ring still returns true from ST_IsValid. It matters because of what consumes the data: RFC 7946 GeoJSON specifies counter-clockwise exteriors, shapefiles specify clockwise, and rendering, tiling and some point-in-polygon implementations behave differently when the convention is broken.
Which direction is correct?
It depends entirely on the format. RFC 7946 GeoJSON wants counter-clockwise exterior rings and clockwise holes, following the right-hand rule. The shapefile specification is the opposite. PostGIS does not care internally but preserves whatever you give it. Store the convention per output rather than picking a house style.
How do I detect the direction of a ring?
By the sign of its area computed with the shoelace formula. A positive signed area means counter-clockwise in a standard right-handed coordinate system, negative means clockwise. Shapely exposes this as is_ccw on a LinearRing, which is the same computation with the sign interpreted for you.
Is reversing a ring a safe repair?
Yes — it is the safest repair available. Reversing the vertex order changes no coordinate, so the shape, area and topology are identical; only the traversal direction changes. That makes it one of the few normalisations that can run unattended without an area-loss guard.
Related
- Geometry Validity Checks for Vector Data — the validity rules orientation deliberately sits outside of
- Mapping Attribute Constraints to GeoJSON Schemas — the other half of RFC 7946 conformance
- Shapefile vs GeoPackage Schema Enforcement — more format conventions that rewrite your data on write