Enforcing Ring Orientation and Winding Order in GeoJSON Jump to heading
Ring orientation is the quality defect that passes every validity check and then breaks the map. A polygon whose exterior ring is wound clockwise is a perfectly legal OGC simple feature — is_valid returns true, the area is right, the topology is right — but under RFC 7946 it is non-conformant GeoJSON, and a renderer that applies the specification literally will fill the entire world and punch your polygon out of it as a hole. This procedure detects wrong-way rings, normalizes them deterministically, and gates the rule where it matters: on the published file. It sits in the normalize rung of geometry validity and repair rules, inside Spatial Data Quality Validation & Geometry Integrity.
The steps map to configure (Step 1, the rule), execute (Steps 2–3, detect and normalize), validate (Step 4, re-check after every writer), and log (Step 5, the CI gate). The reason this is a separate procedure rather than a line in the validity gate is Step 4: orientation is not a property of your data, it is a property of the last writer that touched it, so checking it once in the middle of a pipeline proves nothing about what shipped.
Prerequisites checklist Jump to heading
Step 1: Know which rule you are enforcing, and where Jump to heading
Two conventions exist and they are opposites, which is the whole source of the confusion.
| Format | Exterior ring | Interior ring (hole) | Enforced by |
|---|---|---|---|
| GeoJSON (RFC 7946 §3.1.6) | Counter-clockwise | Clockwise | The specification; renderers may rely on it |
| ESRI Shapefile | Clockwise | Counter-clockwise | The format specification |
| OGC Simple Features / WKB | Unspecified | Unspecified | Nothing — orientation is not part of validity |
| PostGIS storage | As inserted | As inserted | Nothing, unless ST_ForcePolygonCCW is applied |
Because the middle two rows enforce nothing, orientation survives arbitrarily through a pipeline and then flips the moment a shapefile writer touches it. A dataset can therefore be correct at the source, correct in the database, and wrong in the published file — which is why the assertion belongs on the artefact.
Step 2: Detect wrong-way rings by signed area Jump to heading
The winding direction of a ring is the sign of its shoelace area. Positive is counter-clockwise in a standard right-handed coordinate system; negative is clockwise.
# shapely >= 2.0 — Python 3.10+
from shapely.geometry import Polygon
from shapely.geometry.polygon import LinearRing
def signed_area(ring: LinearRing) -> float:
"""Shoelace area: positive = counter-clockwise, negative = clockwise."""
coords = list(ring.coords)
total = 0.0
for (x1, y1), (x2, y2) in zip(coords, coords[1:]):
total += (x2 - x1) * (y2 + y1)
return -total / 2.0
def rfc7946_violations(poly: Polygon) -> list[str]:
problems: list[str] = []
if signed_area(poly.exterior) < 0:
problems.append("exterior-clockwise")
for i, hole in enumerate(poly.interiors):
if signed_area(hole) > 0:
problems.append(f"hole-{i}-counter-clockwise")
return problems
square_cw = Polygon([(0, 0), (0, 10), (10, 10), (10, 0), (0, 0)])
print(square_cw.is_valid) # True — orientation is not a validity concern
print(rfc7946_violations(square_cw)) # ['exterior-clockwise']
Report the two classes separately. A wrong exterior is usually a writer artefact affecting the whole layer uniformly; a wrong hole in an otherwise correct polygon usually means the hole was added by a different tool than the shell, which is worth knowing about the source.
Step 3: Normalize orientation deterministically Jump to heading
Shapely’s orient takes a sign and returns a polygon wound accordingly. Use it on every polygon and every part of every multipolygon; do not reverse coordinate lists by hand, because getting a hole backwards is easy and the result is still valid.
# shapely >= 2.0 — Python 3.10+
from shapely.geometry import MultiPolygon, Polygon
from shapely.geometry.polygon import orient
def to_rfc7946(geom):
"""Counter-clockwise exteriors, clockwise holes — RFC 7946 sign is +1.0."""
if isinstance(geom, Polygon):
return orient(geom, sign=1.0)
if isinstance(geom, MultiPolygon):
return MultiPolygon([orient(p, sign=1.0) for p in geom.geoms])
return geom # points and lines have no winding
fixed = to_rfc7946(square_cw)
print(rfc7946_violations(fixed)) # []
print(fixed.equals(square_cw)) # True — the shape is identical
The final line is the reassurance worth keeping in the test suite: normalization changes the serialization order of the coordinates, not the geometry. Area, bounds, topology and every spatial predicate are unaffected, which is why this operation needs no area-delta budget — unlike a repair, it cannot move anything.
Apply it as the last transformation before serialization, in the writer stage:
# geopandas >= 0.14 — Python 3.10+
gdf["geometry"] = gdf.geometry.map(to_rfc7946)
gdf.to_file("published/parcels.geojson", driver="GeoJSON")
Step 4: Re-check after every writer, not before Jump to heading
This is the step that gets skipped, and it is the reason wrong-way rings reach production in pipelines that “already handle orientation”.
# geopandas >= 0.14, fiona >= 1.9 — Python 3.10+
import geopandas as gpd
gdf["geometry"] = gdf.geometry.map(to_rfc7946) # normalized in memory
gdf.to_file("interim/parcels.shp") # shapefile writer flips them
round_tripped = gpd.read_file("interim/parcels.shp")
violations = round_tripped.geometry.map(rfc7946_violations)
print(int(violations.map(bool).sum()), "of", len(round_tripped), "now violate RFC 7946")
If a shapefile or file geodatabase sits anywhere between your normalization and your GeoJSON output — a common shape for pipelines that hand data to a desktop step, as in translating PostGIS schemas to Esri File Geodatabase — the normalization must be re-applied after it. The durable fix is to make orientation a property of the publisher rather than of the pipeline: whatever produces the GeoJSON orients immediately before writing, unconditionally.
Step 5: Gate the published artefact in CI Jump to heading
# tests/test_geojson_orientation.py — pytest >=7, geopandas >=0.14
import geopandas as gpd
import pytest
from quality.orientation import rfc7946_violations
PUBLISHED = "published/parcels.geojson"
@pytest.fixture(scope="module")
def published():
return gpd.read_file(PUBLISHED)
def test_published_geojson_is_rfc7946_conformant(published):
offenders = {
fid: problems
for fid, problems in zip(published["feature_id"],
published.geometry.map(rfc7946_violations))
if problems
}
assert offenders == {}, f"{len(offenders)} feature(s) violate RFC 7946: {list(offenders)[:5]}"
def test_detector_actually_detects():
# Negative control: a deliberately clockwise square must be reported.
from shapely.geometry import Polygon
cw = Polygon([(0, 0), (0, 1), (1, 1), (1, 0), (0, 0)])
assert rfc7946_violations(cw) == ["exterior-clockwise"]
The negative control matters more here than almost anywhere else in the quality suite, because the positive test passes trivially on an empty result set. A detector with an inverted sign convention reports zero violations on every input, and the suite stays green while every published file is wrong. Run both alongside the conformance scorecard so orientation appears as a published measure rather than a hidden assumption.
Verification Jump to heading
A conformant published file yields an empty offender set, and the CI log line to look for is the assertion’s absence of output. To verify by hand on a single feature, the first two coordinates of an exterior ring in a small, axis-aligned polygon should move counter-clockwise — for a square starting at the lower-left corner, the second coordinate is to the right, not above:
# jq >= 1.6 — first three positions of the first feature's exterior ring
jq '.features[0].geometry.coordinates[0][0:3]' published/parcels.geojson
# [[8.531,47.372],[8.533,47.372],[8.533,47.374]] → x increases first: counter-clockwise
Troubleshooting Jump to heading
| Symptom | Likely cause | Fix |
|---|---|---|
| A polygon renders as the whole world with a hole where it should be | Exterior ring clockwise in published GeoJSON, consumed by a strict renderer | Apply orient(sign=1.0) in the writer stage and re-check the artefact, not the frame |
| Orientation is correct locally but wrong in production output | A shapefile or FGDB step between normalization and publication | Re-orient after the round-trip; make the publisher own the rule |
orient appears to do nothing |
The geometry is a MultiPolygon and only the collection, not its parts, was passed |
Map orient over geom.geoms and rebuild the multipolygon |
| Holes render filled | Interior rings share the exterior’s winding | Check signed_area per interior ring; the rule is opposite for holes |
| Validity tests pass but a portal rejects the upload | The portal validates RFC 7946 conformance, which is_valid does not cover |
Add the orientation assertion to the pre-publication gate alongside the JSON-LD dataset checks |
Related Jump to heading
- Geometry Validity & Repair Rules — the parent stage and where normalization sits on the repair ladder
- Repairing Self-Intersecting Polygons with make_valid — the repair whose area delta a reversed ring can mimic
- Translating PostGIS Schemas to Esri File Geodatabase — the round-trip that reverses the convention
- Emitting schema.org Dataset JSON-LD for Open Data Portals — the other publication-time conformance gate