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.

Winding order under the two conventionsTwo panels showing the same donut polygon. On the left, the RFC 7946 convention: the exterior ring runs counter-clockwise and the hole runs clockwise. On the right, the shapefile convention reverses both. The shape, area and topology are identical in each case.exterior CCWhole CWRFC 7946 GeoJSONright-hand ruleexterior CWhole CCWShapefile conventionexactly reversedSame shape, same area, both OGC-valid — only the traversal differs
Neither convention is more correct; the format decides, and OGC validity has no opinion at all.

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.

Where a reversed ring actually breaks somethingGrid of five consumers with what each does when a ring is wound against its expected convention: an OGC predicate, a renderer using the winding fill rule, a vector tile encoder, a strict GeoJSON reader and a point-in-polygon implementation.Effect of a reversed ringOGC predicates (GEOS)nothing — orientation is insignificantWinding-rule rendererholes fill in; the polygon looks invertedVector tile encodergeometry rejected or rendered wrongStrict RFC 7946 readernon-conformant, may warn or refuseSome point-in-polygon codeinside/outside invertedNothing upstream reports it, which is why the check has to be explicit — a reversed ring passes every validity gate you already run.
The defect is invisible until it reaches a consumer that cares, and then it is very visible.

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.

Normalising orientation without touching the shapeChain of four steps: detect the direction of every ring from its signed area, reverse only the rings that are wound wrongly, assert that the area and geometry are unchanged, and re-check after the file has been written because drivers rewrite orientation.Signed areaper ringpositive = CCWReverse the wrong onescoordinates unchangedAssert unchangedarea + equals()Re-check after writedrivers rewrite it
Reversal is the safest repair there is — and the last box is why it still needs verifying at the file boundary.

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

Back to Geometry Validity Checks for Vector Data