Fixing Ring Orientation Across a Whole Layer
Call geom.normalize() on every geometry and write it back: Shapely reorders exterior rings counter-clockwise and interior rings clockwise, matching the OGC convention. Nothing will have raised beforehand, because ST_IsValid does not test orientation — which is precisely why a layer can carry thousands of wrongly-wound rings and pass every validity check you own.
This page belongs to the Geometry Validity & Topology Repair guide, which covers the rules orientation is not part of.
Why This Matters
The OGC Simple Features specification states the convention — exterior rings counter-clockwise, interior rings clockwise, with the Y axis up — but GEOS-based predicates do not depend on it. ST_Intersects, ST_Within and ST_Area all determine inside and outside from ring nesting rather than from winding, so a reversed ring behaves identically under every function SpatiaLite exposes.
Other consumers are less forgiving. Renderers that use a non-zero fill rule draw a reversed hole as solid. Tile pipelines that encode geometry into vector-tile format assume the convention and produce inverted polygons when it is violated. Some GIS packages compute signed area directly and report negatives. The result is a layer that is provably correct inside your database and visibly wrong in someone else’s tool — the most expensive kind of defect, because the evidence lives outside the system you control.
Prerequisites
- Python 3.9+ with
shapely2.0+ - Read and write access to the layer, and a backup
mod_spatialiteif you want to filter rows in SQL; otherwise pure Python works- Familiarity with decoding GeoPackage geometry BLOBs — see How to Read GeoPackage Geometry Blobs in Python
Primary Method
# Detect and normalise ring orientation across a layer
from shapely import from_wkb, to_wkb
from shapely.geometry import Polygon, MultiPolygon
def rings_are_conventional(geom) -> bool:
"""True when every exterior ring is CCW and every interior ring is CW."""
parts = geom.geoms if isinstance(geom, MultiPolygon) else [geom]
for part in parts:
if not part.exterior.is_ccw:
return False
if any(ring.is_ccw for ring in part.interiors):
return False
return True
def normalise_orientation(blob: bytes) -> tuple[bytes | None, bool]:
"""Return (new_wkb_or_None, was_changed)."""
geom = from_wkb(blob)
if not isinstance(geom, (Polygon, MultiPolygon)):
return None, False # orientation is meaningless for points and lines
if rings_are_conventional(geom):
return None, False
return to_wkb(geom.normalize()), True
is_ccw on a LinearRing is the direct test: it computes the signed area and reports whether the traversal is counter-clockwise in the coordinate system’s own orientation. Checking it per ring, rather than trusting normalize() to be idempotent, means the pass only writes the rows that actually need writing.
Step-by-Step Walkthrough
1. Count how many rows are affected
import sqlite3
from shapely import from_wkb
conn = sqlite3.connect("file:field.gpkg?mode=ro", uri=True)
wrong = 0
total = 0
for (blob,) in conn.execute("SELECT geom FROM parcels WHERE geom IS NOT NULL"):
geom = from_wkb(gpb_to_wkb(blob))
total += 1
if not rings_are_conventional(geom):
wrong += 1
print(f"{wrong} of {total} geometries are not in conventional orientation")
As with validity, the ratio is the diagnosis. All rows wrong means a single writer produced the whole layer with the wrong convention — usually a hand-rolled WKB encoder, or an import from a format with the opposite default. A scattering of rows means individual edits.
2. Distinguish exterior from interior failures
The two have different causes and it is worth knowing which you have.
from collections import Counter
kinds = Counter()
for (blob,) in conn.execute("SELECT geom FROM parcels WHERE geom IS NOT NULL"):
geom = from_wkb(gpb_to_wkb(blob))
parts = geom.geoms if geom.geom_type == "MultiPolygon" else [geom]
for part in parts:
if not part.exterior.is_ccw:
kinds["exterior reversed"] += 1
for ring in part.interiors:
if ring.is_ccw:
kinds["interior reversed"] += 1
for kind, n in kinds.most_common():
print(f"{n:>6} {kind}")
Exterior-only failures point at an encoder that emitted rings in input order without normalising. Interior-only failures point at holes assembled separately from their exteriors — commonly a script that built a polygon from two independently-digitised rings.
3. Normalise the layer
# Rewrite only the rows whose orientation is wrong
import sqlite3
conn = sqlite3.connect("field.gpkg", isolation_level=None)
updates = []
for fid, blob in conn.execute(
"SELECT fid, geom FROM parcels WHERE geom IS NOT NULL"
):
new_wkb, changed = normalise_orientation(gpb_to_wkb(blob))
if changed:
updates.append((new_wkb, fid))
conn.execute("BEGIN IMMEDIATE")
conn.executemany(
"UPDATE parcels SET geom = GeomFromWKB(?, 4326) WHERE fid = ?", updates
)
conn.execute("COMMIT")
print(f"rewrote {len(updates)} geometries")
4. Understand what else normalize() changes
normalize() does more than winding. It also sorts the components of a multipart geometry into a canonical order and rotates each ring to start at its lowest vertex. None of that changes the shape, but it does change the serialised bytes — so a test comparing WKB before and after will report a difference for rows whose orientation was already correct.
Verification
Confirm the pass reached everything, and that nothing else moved.
# After the pass: no non-conventional rings, and area unchanged
remaining = sum(
0 if rings_are_conventional(from_wkb(gpb_to_wkb(b))) else 1
for (b,) in conn.execute("SELECT geom FROM parcels WHERE geom IS NOT NULL")
)
assert remaining == 0, f"{remaining} geometries still non-conventional"
-- Area is orientation-independent, so it must be unchanged to the last digit
SELECT round(sum(ST_Area(geom)), 9) AS total_area FROM parcels;
Unlike a validity repair, a normalisation pass must leave total area exactly as it was. Any movement means something other than orientation changed, and is worth investigating before the container ships.
Alternative Approaches or Edge Cases
Normalising at write time instead. The durable fix is to normalise in the serialization path, so nothing wrongly wound ever enters the container — the pattern in How to Serialize MultiPolygon Geometries to WKB in Python does exactly this. A layer-wide pass is a one-off correction; the write-path change is what stops it recurring.
shapely.geometry.polygon.orient. Where you need a specific sign rather than the canonical form, orient(polygon, sign=1.0) sets exterior counter-clockwise and interiors clockwise without the vertex rotation and component reordering normalize() also performs. It touches fewer bytes, which is useful when minimising a diff between two containers.
Layers with mixed geometry types. Points and lines have no meaningful orientation, and calling ring logic on them raises. Filter on geometry type before the check, as the isinstance guard in the primary method does.
Troubleshooting
The pass reports every row as wrong, twice in a row
Cause: The comparison is being made against the wrong convention. is_ccw reports counter-clockwise in the coordinate system as stored — for a projected system with Y increasing north, that matches the OGC convention; for a system with a flipped axis order, it does not. Fix: Confirm the axis order of the layer’s reference system before treating the result as a defect.
Area changes after normalising
Cause: Something other than orientation was rewritten — most likely the geometry was also invalid, and the write path repaired it. Fix: Run a validity scan first and repair separately, so the two effects are not conflated. Orientation alone cannot change area.
Downstream tool still renders holes as solid
Cause: The consumer is reading a cached copy, or the layer it reads is a different one. Fix: Verify against the container the consumer actually opens, and check whether an intermediate export step re-encodes the geometry with its own convention.
Frequently Asked Questions
If GEOS ignores orientation, why does the specification state it?
Because the specification is an interchange contract, not an implementation guide. It exists so that a producer and a consumer who have never met can agree on what the bytes mean, and winding is part of that agreement even where a particular engine can infer the answer without it. A library that tolerates a violation is being helpful; the file is still non-conforming, and the next consumer may be less accommodating.
Should the check run in the quality gate?
Yes, as a warning rather than a blocker in most pipelines. Wrongly wound rings do not make a container unusable, so failing publication over them is usually disproportionate — but they do indicate a producer that is not normalising, which is worth knowing about before the next thing that producer emits is genuinely invalid. The gate design in Data Quality Gates & Monitoring covers the warning-versus-blocking split.
Does GeoJSON have the same convention?
It does, and it acquired it late — the original GeoJSON specification said nothing about winding, and the 2016 revision adopted the right-hand rule, which is the same convention by another name. Data produced against the earlier specification is therefore commonly unwound, which makes GeoJSON imports one of the more reliable sources of the problem this page fixes.
Related
- Geometry Validity & Topology Repair — parent guide: what validity covers, and what it does not
- How to Serialize MultiPolygon Geometries to WKB in Python — normalising in the write path so this pass is never needed again
- How to Repair Self-Intersecting Polygons with Shapely — the repair for a real validity failure
- Data Quality Gates & Monitoring for Field Datasets — where an orientation check belongs in a pipeline