Fixing Axis-Order Inversion in Cross-Agency GeoJSON

A mutual-aid task force pushes a GeoJSON layer of 240 damage-assessment points into the shared incident map during a wildfire in northern New Mexico. The features validate cleanly, the attribute table looks perfect, and the layer loads without error — but every point plots in the Southern Ocean, roughly 6,000 kilometres off the coast of Antarctica. The sending agency’s export tool wrote each position as latitude then longitude, so a point that should read [-106.6, 34.1] arrived as [34.1, -106.6]. The file is perfectly valid GeoJSON; it is geographically inside-out. This is axis-order inversion, and it is the single narrow failure mode this page solves — detecting lat/lon-swapped features that another agency’s tool produced, correcting only the features that are actually inverted, and locking the fix in place so no downstream reprojection quietly swaps them back.

Root Cause and Operational Impact

The root cause is a genuine ambiguity baked into the standards. The GeoJSON specification (RFC 7946) is unambiguous: a position is always [longitude, latitude], x before y, decimal degrees, WGS 84. But the authoritative definition of EPSG:4326 maintained under the Open Geospatial Consortium (OGC) and the EPSG registry lists the axes in the opposite order — latitude first, then longitude. A tool that serializes GeoJSON straight from a CRS-aware library respecting authority axis order, or one that hand-builds features from a database with a lat, lon column pair, will emit positions in authority order while still stamping them EPSG:4326. Both files claim WGS 84; one obeys the GeoJSON byte order and the other obeys the CRS authority order. Nothing in the file signals which convention was used, so the receiving system cannot tell a correct feature from a transposed one by inspecting the header — only by inspecting where the points land.

This is dangerous, not merely inconvenient, because a transposed coordinate rarely looks broken — it looks like a valid point somewhere else on Earth. A New Mexico incident at 34N 106W silently relocates to 34E 106S; a Gulf Coast shelter at 29N 90W jumps to 29E 90S. During a mass-casualty response those points feed the Common Operating Picture that dispatchers, aircraft, and resource planners all read as ground truth. Under both the National Incident Management System (NIMS) and the Federal Emergency Management Agency (FEMA), and under ISO 22320 for incident-management information exchange, shared operational data must be traceable and reconstructable for after-action review. A layer that was silently transposed, or silently “fixed” by an analyst dragging points on a map, is neither. The correction has to be deterministic and auditable, which is why axis-order enforcement belongs in the Coordinate Reference Systems for Disaster Zones contract rather than in a one-off cleanup script, and why it shares the same discipline as Handling Missing CRS in Field-Collected GPS Logs.

Detecting and correcting axis-order inversion in cross-agency GeoJSON An incoming GeoJSON position labelled EPSG:4326 is written latitude-first, so a point that belongs in New Mexico plots thousands of kilometres away outside the incident bounding box. Each feature runs through three ordered gates: test the coordinate against the expected bounds, test its longitude-latitude transpose against the same bounds, and decide. A feature inside the bounds is accepted unchanged, a feature that only fits when transposed is swapped and logged, and a feature that fits in neither orientation is quarantined. The corrected point returns inside the incident bounding box with an audit record, and every downstream pyproj transform is constructed with always_xy set to true so the fix is never reversed. Incoming GeoJSON "crs": "EPSG:4326" [ 34.1, -106.6 ] written lat, lon → plots in Southern Ocean outside bounds (34E 106S) wrong hemisphere Axis-order check Gate 1 · bounds test inside expected bbox? → accept Gate 2 · transpose test swap fits bbox? → invert + log Gate 3 · neither fits quarantine for review swap x,y Corrected "crs": "EPSG:4326" [ -106.6, 34.1 ] now lon, lat (x,y) + audit record incident bounding box inside bounds (34N 106W) Enforce always_xy=True on every downstream pyproj Transformer so no reprojection reintroduces authority (lat,lon) axis order

Tiered Resolution Strategy

Correct the layer in ordered tiers, from the definitive fix down to a safe default that is always flagged for audit. Never silently transpose an entire file on a hunch — a blanket swap will invert the features that were already correct.

  1. Establish the axis contract (definitive). Declare that every position crossing the boundary is [longitude, latitude] per RFC 7946. This is the invariant the rest of the pipeline defends; the goal is to make incoming data conform to it, not to guess per feature at query time.
  2. Detect inversion by bounds, per feature. Test each coordinate against the incident’s expected bounding box. If it falls outside but its (lat, lon) transpose falls inside, the feature is inverted. Deciding per feature — not per file — survives mixed exports where only some records were swapped.
  3. Transpose only the failing features. Swap x and y for features that fail the direct test and pass the transposed test, and leave every already-correct feature untouched. A feature that fits in neither orientation is a different problem — quarantine it rather than forcing a swap.
  4. Enforce always_xy downstream (safe default). Build every pyproj Transformer with always_xy=True so no later reprojection honours authority axis order and re-inverts the corrected coordinates. This is the one setting that keeps the fix from silently unwinding two stages later.
  5. Emit an audit record for every correction. Original coordinate, corrected coordinate, decision reason, and dataset identifier — so any transposed feature is reproducible against the exact bounds and logic that produced it, satisfying the reconstructability that after-action review demands.

Production Python Implementation

The routine below carries the full resolution path: a per-feature bounds test, transpose detection, quarantine for coordinates that fit no orientation, always_xy enforcement for downstream reprojection, structured logging, explicit exception handling, and an immutable audit record per correction. Bounds are a parameter, not a literal, so the incident’s operating area can be committed and versioned alongside the rest of the CRS contract. Senior-engineer assumptions apply: pyproj and shapely are available, and the expected bounds are supplied in WGS 84 for the active incident.

python
from __future__ import annotations

import logging
from dataclasses import dataclass, field, asdict
from datetime import datetime, timezone
from enum import Enum
from typing import Iterable, Optional

from pyproj import Transformer

logger = logging.getLogger("incidentgis.axisorder")


class Decision(str, Enum):
    ACCEPTED = "accepted_in_bounds"
    TRANSPOSED = "transposed_lat_lon_swap"
    QUARANTINED = "quarantined_out_of_bounds"
    ERROR = "error_skipped"


@dataclass(frozen=True)
class Bounds:
    """Expected WGS 84 extent of the active incident (lon/lat degrees)."""
    min_lon: float
    min_lat: float
    max_lon: float
    max_lat: float

    def contains(self, lon: float, lat: float) -> bool:
        return (
            self.min_lon <= lon <= self.max_lon
            and self.min_lat <= lat <= self.max_lat
        )


@dataclass
class AuditEntry:
    """Immutable record of a single axis-order decision."""
    feature_id: str
    decision: str
    original: tuple[float, float]
    corrected: tuple[float, float]
    dataset: str
    recorded_at: str = field(
        default_factory=lambda: datetime.now(timezone.utc).isoformat()
    )


class AxisOrderCorrector:
    """Detect and correct lat/lon-swapped GeoJSON features by bounds.

    Every correction is logged and appended to ``audit_log`` so a normalized
    layer can be reconstructed against the exact bounds that produced it.
    """

    def __init__(self, bounds: Bounds, dataset: str, target_epsg: int = 4326) -> None:
        self.bounds = bounds
        self.dataset = dataset
        # always_xy=True pins x,y (lon,lat) order so reprojection never
        # reintroduces the CRS authority (lat,lon) axis order of the source.
        self._to_target = Transformer.from_crs(
            "EPSG:4326", f"EPSG:{target_epsg}", always_xy=True
        )
        self.audit_log: list[AuditEntry] = []

    def _record(
        self,
        feature_id: str,
        decision: Decision,
        original: tuple[float, float],
        corrected: tuple[float, float],
    ) -> AuditEntry:
        entry = AuditEntry(
            feature_id=feature_id,
            decision=decision.value,
            original=original,
            corrected=corrected,
            dataset=self.dataset,
        )
        self.audit_log.append(entry)
        return entry

    def correct_position(
        self, feature_id: str, position: tuple[float, float]
    ) -> Optional[tuple[float, float]]:
        """Return a bounds-valid (lon, lat), or None if it must be quarantined."""
        try:
            lon, lat = float(position[0]), float(position[1])
        except (TypeError, ValueError, IndexError) as exc:
            logger.error("axis_parse_failed", extra={"feature": feature_id}, exc_info=exc)
            self._record(feature_id, Decision.ERROR, (0.0, 0.0), (0.0, 0.0))
            return None

        # Gate 1: already correct — position sits inside the incident bounds.
        if self.bounds.contains(lon, lat):
            self._record(feature_id, Decision.ACCEPTED, (lon, lat), (lon, lat))
            logger.debug("axis_accept", extra={"feature": feature_id})
            return (lon, lat)

        # Gate 2: transpose fits — the source wrote lat,lon; swap to lon,lat.
        if self.bounds.contains(lat, lon):
            entry = self._record(feature_id, Decision.TRANSPOSED, (lon, lat), (lat, lon))
            logger.warning("axis_transposed", extra={"audit": asdict(entry)})
            return (lat, lon)

        # Gate 3: neither orientation fits — do not force a swap; quarantine.
        entry = self._record(feature_id, Decision.QUARANTINED, (lon, lat), (lon, lat))
        logger.warning("axis_quarantined", extra={"audit": asdict(entry)})
        return None

    def normalize(
        self, features: Iterable[dict]
    ) -> list[dict]:
        """Yield features with Point geometry normalized to lon,lat x,y order."""
        cleaned: list[dict] = []
        for feature in features:
            fid = str(feature.get("id", feature.get("properties", {}).get("id", "unknown")))
            geom = feature.get("geometry") or {}
            if geom.get("type") != "Point":
                # Non-point geometries need element-wise handling; skip safely here.
                logger.debug("axis_skip_nonpoint", extra={"feature": fid, "type": geom.get("type")})
                cleaned.append(feature)
                continue
            fixed = self.correct_position(fid, tuple(geom.get("coordinates", ())))
            if fixed is None:
                continue  # quarantined — excluded from the normalized layer.
            geom["coordinates"] = list(fixed)
            cleaned.append(feature)
        logger.info(
            "axis_normalize_done",
            extra={"dataset": self.dataset, "in": None, "out": len(cleaned),
                   "corrections": sum(1 for e in self.audit_log if e.decision == Decision.TRANSPOSED.value)},
        )
        return cleaned

The audit_log is the load-bearing output here. Persisting it as a committed, content-hashed artifact lets a reviewer replay every transposition and confirm that no coordinate was flipped without cause — the reproducibility guarantee the Coordinate Reference Systems for Disaster Zones contract exists to provide, and the same standard a shared PostGIS store for emergency response should enforce on ingest.

Validation Checklist

Verify every item before running the corrector against a live cross-agency feed.

  • Incident bounds are passed as a parameter and committed under version control — no bounding box hard-coded in the field build.
  • Every pyproj Transformer in the pipeline is constructed with always_xy=True, verified by test, so no reprojection reintroduces authority axis order.
  • Features already inside the bounds are accepted unchanged — the corrector never double-swaps a correct coordinate.
  • Coordinates that fit neither orientation are quarantined for human review, not force-transposed.
  • The bounds test tolerates points on the exact edge of the operating area (inclusive comparison) so a shelter on the incident boundary is not quarantined.
  • Null-island (0.0, 0.0) positions are caught before this stage or fall to quarantine — they are inside no real incident box and must never be silently accepted.
  • Structured logs route to the incident logging sink, not stdout, and every decision appears in audit_log.
  • Non-Point geometries (LineString, Polygon) are handled element-wise by a dedicated path, not passed through unchecked.

Edge Cases and Gotchas

  • Square operating areas near the equator. When the incident bounding box is roughly square and straddles low latitudes, a coordinate and its transpose can both fall inside the box, so the bounds test cannot decide. Detect this ambiguity explicitly and quarantine rather than guess; disambiguate with a secondary signal such as a known landmark, county polygon, or the sending agency’s declared convention.
  • The always_xy trap on reprojection. The most common regression is fixing the input, then reprojecting to a projected CRS with a Transformer built without always_xy=True. pyproj then honours the EPSG:4326 authority order and consumes your data as latitude-first, silently re-inverting everything. Pin always_xy=True on every Transformer, and assert it in a unit test.
  • Null-island drift. A failed geocode often emits (0.0, 0.0). Its transpose is still (0.0, 0.0), so a bounds test will quarantine it — but treat null island as its own first-class reject so it is never confused with a genuine axis flip. This is the same guard applied in Catching Null-Island Coordinates Before They Reach the COP.
  • Mixed-orientation files. Some exports interleave correct and inverted features when records were merged from two upstream tools. This is exactly why detection runs per feature, not per file; a file-level heuristic that swaps everything on the first bad point corrupts the good half of the layer.
  • Agency-specific datum anomalies. A feature declared WGS 84 but actually captured on a local or legacy datum introduces a constant offset that can nudge an edge point just outside the bounds and trigger a spurious transpose. Validate the source datum at registration and reproject to the incident CRS before axis correction, not after — the same ordering discipline that governs Converting CADRG Maps to GeoJSON with Python.

Frequently Asked Questions

Why do some agencies send GeoJSON with latitude and longitude swapped? GeoJSON per RFC 7946 fixes coordinate order as longitude then latitude, but the underlying EPSG:4326 authority definition lists latitude first. Tools that serialize from a CRS-aware library in authority axis order, or that hand-build GeoJSON from a lat,lon database column, emit positions in the wrong order while still labelling them WGS 84. The file is syntactically valid GeoJSON but geographically transposed.

How can you detect axis-inverted coordinates automatically? Test each position against the incident’s expected bounding box. A coordinate that falls outside the operating area but lands inside it when longitude and latitude are swapped is almost certainly inverted. A New Mexico incident at 34N 106W that arrives as 34E 106S plots in the Southern Ocean, so a bounds test catches it deterministically without human review.

How do you stop reprojection from re-inverting the fixed coordinates? Construct every pyproj Transformer with always_xy=True. Without that flag pyproj honours the CRS authority axis order and will emit latitude-first output for EPSG:4326, silently undoing the correction. Pinning always_xy across the whole pipeline makes x,y ordering the single invariant every stage agrees on.

Up: Coordinate Reference Systems for Disaster Zones

Other guides in Coordinate Reference Systems for Disaster Zones: Python Workflows & Incident GIS Architecture