Detecting Axis-Order Swaps in Coordinate Input

Catch latitude-first coordinates before they enter the pipeline, using range, area-of-use and land-mask checks that distinguish a swap from a genuinely unusual position.

A swapped coordinate pair is the cheapest bug in spatial software to create and one of the more expensive to find. Two conventions disagree about which number comes first, both are legitimate, and a system that reads one as the other places features in a mirrored world where most of them fall in the ocean. This guide builds a systematic detector, as part of the ingestion gate described in coordinate reference system normalization.

When to Use This Approach

Run the check on every geographic coordinate entering the system, not on a sample. The cases it catches are the ones a spot inspection misses, because obvious swaps are obvious and the damaging ones look plausible.

Signal Catches Misses
Range check on latitude Any pair where longitude exceeds 90 Anything within ±90 in both
Area-of-use test Pairs that fall outside the frame’s own region Regions symmetric about the diagonal
Land mask Pairs landing in open ocean Coastal and island cases
Source consistency A whole file swapped relative to its siblings The first file from a new source

No single test is sufficient, which is why the detector runs them in sequence and reports which one fired. A pair that fails the range check is certain; one that fails only the land mask is a suspicion worth surfacing to a human.

Three swapped pairs and how detectable each one isA pair with a longitude above ninety is certainly swapped, one landing in open ocean is very likely swapped, and one landing on land in another country is undetectable without source context.certainvery likelyundetectable alonelatitude above 90arithmetically impossiblereject outrightlands in open oceanpossible but improbableflag for reviewlands on land elsewhereboth readings plausibleneeds source contextThe third column is why file-level consistency matters more than any per-point test
Per-point tests thin out fast. They catch the majority and leave a residue that only a comparison across the file — do these points agree with each other and with the source's declared region? — can resolve.

Implementation

The detector runs cheap tests first and returns a verdict with the reason and a confidence, so the caller can reject the certain cases and queue the suspicious ones.

import logging
from dataclasses import dataclass
from typing import Optional, Sequence

from pyproj import CRS

log = logging.getLogger("axis_order")


@dataclass(frozen=True)
class SwapVerdict:
    swapped: Optional[bool]      # True, False, or None for "cannot tell"
    confidence: float
    reason: str


def check_pair(x: float, y: float, crs: CRS,
               on_land=None) -> SwapVerdict:
    """Test one coordinate pair. x is the first value as read, y the second."""
    if not crs.is_geographic:
        return SwapVerdict(False, 1.0, "projected frame: this test does not apply")

    # 1. Arithmetic impossibility — the strongest signal available.
    if abs(y) > 90.0 and abs(x) <= 90.0:
        return SwapVerdict(True, 1.0, "second value exceeds 90; it cannot be a latitude")
    if abs(x) > 90.0 and abs(y) > 90.0:
        return SwapVerdict(None, 0.0, "both values exceed 90; neither can be a latitude")

    # 2. Area of use — does the swapped reading fit the frame better?
    area = crs.area_of_use
    if area is not None:
        w, s, e, n = area.bounds
        as_read = w <= x <= e and s <= y <= n
        as_swapped = w <= y <= e and s <= x <= n
        if as_swapped and not as_read:
            return SwapVerdict(True, 0.85, "only the swapped reading falls in the frame's region")
        if as_read and not as_swapped:
            return SwapVerdict(False, 0.85, "only the given reading falls in the frame's region")

    # 3. Land mask — weak, and useful exactly where the others are silent.
    if on_land is not None:
        try:
            here, there = on_land(x, y), on_land(y, x)
        except Exception as exc:                    # a mask outage must not block ingestion
            log.info("land mask unavailable (%s); skipping this signal", exc)
            here = there = None
        if here is False and there is True:
            return SwapVerdict(True, 0.55, "the given reading falls in open water, the swap does not")

    return SwapVerdict(None, 0.0, "no signal distinguishes the two readings")

The three-valued verdict is what makes this usable. A binary detector forces every ambiguous case into one of two wrong answers; returning None lets the caller treat “cannot tell” as its own outcome, which for a file from a known-good source usually means proceeding.

File-level agreement is the signal that resolves most of the residue, and it is much stronger than any per-point test because a source is nearly always consistently swapped or consistently correct.

def check_file(pairs: Sequence[tuple[float, float]], crs: CRS,
               sample: int = 200) -> SwapVerdict:
    """Aggregate per-point verdicts across a file; consistency is the real evidence."""
    if not pairs:
        return SwapVerdict(None, 0.0, "no coordinates to test")
    step = max(1, len(pairs) // sample)
    verdicts = [check_pair(x, y, crs) for x, y in pairs[::step]]
    decided = [v for v in verdicts if v.swapped is not None]
    if not decided:
        return SwapVerdict(None, 0.0, f"no signal across {len(verdicts)} sampled points")
    swapped = sum(1 for v in decided if v.swapped)
    share = swapped / len(decided)
    if share > 0.9:
        return SwapVerdict(True, min(0.99, 0.6 + share / 3),
                           f"{swapped} of {len(decided)} sampled points read as swapped")
    if share < 0.1:
        return SwapVerdict(False, min(0.99, 0.6 + (1 - share) / 3),
                           f"{len(decided) - swapped} of {len(decided)} read correctly")
    return SwapVerdict(None, 0.0,
                       f"inconsistent: {swapped} of {len(decided)} read as swapped — "
                       "the file may mix conventions")

The inconsistent case deserves its own outcome rather than a majority vote. A file where a fifth of points look swapped is not a file with a convention problem; it is a file with a data problem, and reordering the whole thing would corrupt the four fifths that were correct.

What the ingestion gate does with each verdictA certain swap is rejected with an explanation, a likely swap is queued for review, an inconsistent file is rejected as a data problem, and an undecidable case proceeds when the source is trusted.certain swaplikely swapinconsistentno signalreject, explainqueue for reviewreject as bad dataproceed if trustedNever reorder silently — a corrected file that nobody knows was corrected is a future mystery
Reordering is the tempting fix and the wrong one. It works, it is invisible, and it leaves an upstream export producing broken data indefinitely — with a downstream system quietly compensating that nobody remembers to remove.

Validation & Testing

from pyproj import CRS

WGS84 = CRS.from_epsg(4326)


def test_impossible_latitude_is_certain():
    v = check_pair(55.95, -3.19, WGS84)          # read as (lon, lat): lat = -3.19, fine
    assert v.swapped is False or v.swapped is None
    v2 = check_pair(-3.19, 155.0, WGS84)         # second value cannot be a latitude
    assert v2.swapped is True and v2.confidence == 1.0


def test_inconsistent_file_is_not_majority_voted():
    pairs = [(-3.19, 55.95)] * 8 + [(155.0, 55.95)] * 2
    v = check_file(pairs, WGS84)
    assert v.swapped is None and "inconsistent" in v.reason


def test_land_mask_outage_does_not_block():
    def broken(_x, _y):
        raise RuntimeError("mask service down")
    v = check_pair(-3.19, 55.95, WGS84, on_land=broken)
    assert v.swapped in (False, None)            # degraded, not failed

The second test is the one that protects the design. A majority vote over an inconsistent file is the obvious implementation and it silently corrupts the correct minority; asserting that it refuses instead is what keeps a future simplification honest.

Gotchas & Edge Cases

Regions symmetric about the diagonal. A frame whose area of use spans similar ranges in both axes — near the equator at low longitudes — makes the area-of-use test silent, because both readings fit. This is where file-level consistency and source context do the work.

A land mask that is too coarse. A low-resolution mask reports coastal points as water and produces false positives on exactly the data most likely to be coastal. Use the mask as a weak signal only, with a confidence low enough that it queues rather than rejects.

Why a coarse land mask produces false positives on the coastA coastal point sits in a mask cell classified as water, so a correct coordinate is reported as landing in the sea and flagged as a suspected swap.land cellwater cella real coastal site falls in the water cellmask says water — flagged as suspectlow confidence — queued, not rejected
A weak signal must carry a weak confidence. Coastal and island data is disproportionately likely to be interesting and disproportionately likely to trip a coarse mask, so this test earns its place only as a queue trigger.

Files that mix conventions. Concatenated exports from two systems, one of each convention. The inconsistent verdict catches this; treating it as a swap would corrupt half the file and treating it as correct would corrupt the other half.

Projected frames caught by the geographic test. Eastings and northings routinely exceed 90 and are not swapped. Short-circuit on the frame type first, as the implementation does, or every projected file will be rejected.

Silent reordering as a “fix”. The most damaging response, because it works. The upstream export keeps producing swapped data, the compensation is invisible, and the day someone reads that source directly the two systems disagree with no record of why.

Frequently Asked Questions

Why not just always reorder to match the library's convention?

Because the reordering is a guess about which convention the source used, and it is applied to data where the guess cannot be checked. Where a source is genuinely known to be latitude-first, express that as a per-source configuration entry — an explicit declaration with a comment explaining the evidence — rather than as an inference made afresh on every file. The difference is that the configuration is visible and reviewable, and the inference is neither.

How many points should a file-level check sample?

A couple of hundred, spread across the file rather than taken from the front. Front-loaded samples miss the common case where a file is a concatenation and the second half differs, and the check is cheap enough that spreading it costs nothing. Beyond a few hundred the confidence stops improving, because the signal is consistency rather than volume.

Does this apply to structured formats that specify an order?

The specification helps and does not settle it, because exporters get it wrong. A format that mandates longitude-first is still routinely written latitude-first by tools that treated it as latitude-first internally, and the file remains syntactically valid. Run the check regardless; on a compliant file it costs microseconds and returns "no signal", which is exactly the right outcome.

What should the rejection message say?

The two readings and where each one lands. "Read as given, this point is in the South Atlantic; read swapped, it is in Edinburgh" is a message that resolves the question in one line for whoever receives it. A bare "axis order suspected" sends someone to reproduce the check by hand, which is the work the detector was supposed to have done.