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…

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 shapely 2.0+
  • Read and write access to the layer, and a backup
  • mod_spatialite if 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

python
# 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.

Which checks notice a reversed ring, and which do notFour consumers of the same wrongly-wound polygon. ST_IsValid passes, because orientation is not a validity rule. Spatial predicates and area functions give correct answers, because GEOS determines inside and outside from ring nesting. A renderer using a non-zero fill rule draws a reversed hole as solid. A vector-tile encoder that assumes the convention produces an inverted polygon. Only the last two notice, and both live outside the database.One reversed ring, four consumersST_IsValidpasses — not a validity rulegives no signal at allpredicates and areacorrect — nesting decidesGEOS does not carenon-zero fill rendererdraws the hole as solidvisible, and outside your systemvector-tile encoderassumes the conventionproduces an inverted polygon
The two green boxes are why this goes unnoticed; the two pink ones are where it is eventually reported, by someone else.

Step-by-Step Walkthrough

1. Count how many rows are affected

python
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.

python
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

python
# 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.

Everything normalize() changes, and what it leaves aloneNormalisation changes three things: ring winding direction, the starting vertex of each ring, and the ordering of components within a multipart geometry. It leaves three things untouched: the set of coordinates, the area, and the shape. Because the first three change the serialised bytes, a byte comparison after normalising reports differences for geometries whose shape did not change at all.changesring winding directionthe starting vertex of each ringthe order of multipart componentsso the bytes differleaves alonethe set of coordinatesthe areathe shapeso the geometry is the samecompare geometrically, never byte for byte, after a normalise pass
This is the reason a round-trip test that compares serialised output fails on perfectly correct geometry.

Verification

Confirm the pass reached everything, and that nothing else moved.

python
# 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"
sql
-- 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.

Where wrongly wound rings come fromThree common sources. A hand-rolled encoder that writes rings in input order without normalising produces exteriors in whatever direction they were captured. A GeoJSON import produced against the pre-2016 specification has no winding guarantee at all. A polygon assembled from independently digitised exterior and interior rings usually gets the interior direction wrong. The distribution of failures across exterior and interior rings tells you which of the three you have.hand-rolled encoderwrites input orderexteriors affectedusually the whole layerold GeoJSON importpre-2016 spec, no ruleeither ring affectedmixed across the layerrings assembled aparthole digitised separatelyinteriors affectedonly rows with holesthe exterior-versus-interior split identifies the source before you look at the code
Counting the two failure kinds separately is what turns a repair into a fix for whatever produced the layer.

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.