CRS Conflict Resolution Across Branches

Catch and reconcile coordinate reference system mismatches before two branches merge into silently corrupted geometry — part of Conflict Resolution & Team Synchronization Workflows.

Coordinate reference system (CRS) conflicts are the most dangerous class of spatial merge problem because they leave no obvious wreckage. When one branch carries a layer in EPSG:4326 and another carries the same layer in EPSG:3857 or a local UTM zone, the coordinates on both sides are individually valid numbers. A version control system sees a clean diff, the merge completes, and the resulting layer contains features whose positions have been silently blended across incompatible reference frames. Nothing throws an error until an analyst notices a parcel sitting in the ocean.

This guide covers how to detect CRS divergence between branches, enforce a canonical-CRS policy, reproject before merge with explicit datum handling, and gate the whole process in continuous integration. It is written for GIS teams, data engineers, and open-source maintainers who manage versioned vector layers where a single transposed axis or unapplied datum shift can quietly poison a released dataset. The CRS is not a cosmetic tag; it is the contract that makes coordinates mean something, and that contract must be verified on every merge.


Prerequisites & Environment Setup

Before building a CRS gate, confirm your environment can resolve, compare, and transform reference systems deterministically:

Install the core dependencies and verify the registry is reachable:

pip install pyproj geopandas shapely
pyproj sync --source-id us_noaa --list-files   # optional: high-accuracy grids
import pyproj
import geopandas as gpd

print("pyproj:", pyproj.__version__, "PROJ:", pyproj.proj_version_str)
crs = pyproj.CRS.from_user_input("EPSG:4326")
print(crs.to_authority())          # ('EPSG', '4326')
print([ax.abbrev for ax in crs.axis_info])   # ['Lat', 'Lon'] — note the order

The axis_info output is the detail most pipelines ignore and the one that causes transposed merges. EPSG:4326 is formally defined as latitude first, longitude second, yet GeoJSON, most web maps, and many CLIs emit longitude first. Fix your CRS registry access, your canonical code, and your axis convention before writing any merge logic. The way CRS metadata is stored and trusted across a repository is part of the broader geospatial data versioning fundamentals and architecture, where the CRS is treated as a first-class, coupled attribute of every layer rather than an afterthought.


Core Algorithmic Patterns

Three patterns turn a fuzzy “the CRS looks different” intuition into a deterministic, testable comparison.

1. CRS Fingerprinting

A fingerprint is a stable, comparable summary of a reference system. Comparing raw CRS objects fails because two logically identical systems can carry different names, remarks, or whitespace in their WKT. The fingerprint normalizes those away and captures only what changes meaning: the resolved authority code, the datum name, the coordinate system type, and the axis order.

import hashlib
import pyproj

def crs_fingerprint(crs_like) -> dict:
    crs = pyproj.CRS.from_user_input(crs_like)
    authority = crs.to_authority(min_confidence=70)  # ('EPSG', '3857') or None
    axes = tuple((ax.abbrev, ax.direction) for ax in crs.axis_info)
    wkt2 = crs.to_wkt(version="WKT2_2019")
    digest = hashlib.sha256(wkt2.encode("utf-8")).hexdigest()[:16]
    return {
        "authority": ":".join(authority) if authority else None,
        "datum": crs.datum.name if crs.datum else None,
        "is_geographic": crs.is_geographic,
        "axis_order": axes,
        "wkt2_sha256": digest,
    }

The wkt2_sha256 catches custom or slightly-modified systems that resolve to no clean authority code, while axis_order catches the transposition case where two branches agree on EPSG:4326 but disagree on whether latitude or longitude comes first. Fingerprinting is O(1) per layer and forms the comparable unit for every downstream check.

2. Authority Normalization

Branches accumulate CRS definitions from many sources: a .prj sidecar, an embedded GeoPackage SRS table, a raw PROJ string, or an ESRI WKT variant. Authority normalization collapses all of these to a single canonical authority code so that “the same CRS expressed three ways” no longer registers as three conflicts.

Input form Resolution strategy Result
Clean EPSG code (EPSG:25832) to_authority() returns it directly EPSG:25832
ESRI WKT or .prj string CRS.from_wkt() then to_authority(min_confidence=70) Nearest EPSG or None
Custom PROJ string CRS.from_proj4() then confidence-scored match Nearest EPSG or custom fingerprint
Missing / undefined Flag for manual assignment — never assume EPSG:4326 Quarantine

When no authority match clears the confidence threshold, keep the WKT2 fingerprint rather than forcing a code. Silently coercing an unknown system into EPSG:4326 is the single most common way a real conflict is hidden. The narrow mechanics of turning mixed, ambiguous, and missing definitions into one authority code are covered in depth in normalizing EPSG codes before a spatial merge.

3. Datum Transformation Pipelines

Reprojection is not a single operation but a selected pipeline. Between two datums such as NAD83 and WGS84 there can be several candidate transformations of differing accuracy, and PROJ picks a default that may use a null shift when high-accuracy grids are absent. The correct pattern is to enumerate candidate pipelines with pyproj.transformer.TransformerGroup and select one explicitly by accuracy, so the same commit always reprojects the same way regardless of which machine runs it.

from pyproj.transformer import TransformerGroup

tg = TransformerGroup("EPSG:4269", "EPSG:4326")  # NAD83 -> WGS84
for t in tg.transformers:
    print(round(t.accuracy or -1, 3), t.description)

If tg.best_available is False, the grids needed for a sub-metre transformation are missing and the pipeline will fall back to a null shift — the classic source of a roughly one-to-two-metre (or larger, for older datums) systematic offset. Selecting the pipeline deterministically, and recording which one was used, makes datum handling reproducible across every branch and every CI runner.


Production Workflow Implementation

The pre-merge CRS gate runs five deterministic steps. It reads the CRS of every changed layer on both the incoming branch and the canonical target, and it refuses to proceed until every layer shares one authority code and axis order.

Pre-merge CRS gate workflow Boxes labelled Fingerprint, Compare vs Canonical, Select Pipeline, Reproject, and Validate & Gate connected left to right by arrows, representing the sequential stages of the coordinate reference system merge gate. Fingerprint CRS Step 1 Compare vs Canonical Step 2 Select Pipeline Step 3 Reproject Step 4 Validate & Gate Step 5

Step 1 — Fingerprint Every Layer

Read the declared CRS from each changed layer on both branches and reduce it to the fingerprint from the previous section. Never trust a filename or a directory convention to imply a CRS — read it from the data.

import geopandas as gpd

def layer_fingerprint(path: str) -> dict:
    gdf = gpd.read_file(path, rows=1)   # header only; geometry not needed here
    if gdf.crs is None:
        return {"path": path, "authority": None, "undefined": True}
    fp = crs_fingerprint(gdf.crs)
    fp["path"] = path
    fp["undefined"] = False
    return fp

Step 2 — Compare Against the Canonical Policy

Load the repository’s canonical CRS from config and diff each fingerprint against it. Distinguish three failure classes: an undefined CRS, a mismatched authority code, and a matching code with a mismatched axis order.

CANONICAL = crs_fingerprint("EPSG:25832")   # from repo config, e.g. a UTM zone

def diff_against_canonical(fp: dict) -> str:
    if fp.get("undefined"):
        return "undefined"
    if fp["authority"] != CANONICAL["authority"]:
        return "authority_mismatch"
    if fp["axis_order"] != CANONICAL["axis_order"]:
        return "axis_mismatch"
    return "ok"

An axis_mismatch is especially insidious: the authority codes agree, so a naive equality check passes, yet every coordinate pair is transposed. This is the exact class of defect that detecting silent reprojection errors in pull requests is designed to surface when the CRS label itself was never updated.

Step 3 — Select the Transformation Pipeline

For any layer flagged authority_mismatch, choose an explicit datum transformation pipeline rather than accepting the default. Record its accuracy so the choice is auditable.

from pyproj.transformer import TransformerGroup

def choose_pipeline(src_authority: str, dst_authority: str):
    tg = TransformerGroup(src_authority, dst_authority, always_xy=True)
    if not tg.transformers:
        raise RuntimeError(f"No transformation from {src_authority} to {dst_authority}")
    best = tg.transformers[0]            # sorted best-first by PROJ
    return best, (best.accuracy or -1.0), tg.best_available

Step 4 — Reproject Before Merge

Reproject each non-canonical layer into the canonical CRS. Use always_xy=True consistently so the axis convention is fixed, and reproject through the metric working CRS if you need to validate area or topology afterwards.

def reproject_to_canonical(path: str, dst: str = "EPSG:25832") -> gpd.GeoDataFrame:
    gdf = gpd.read_file(path)
    if gdf.crs is None:
        raise ValueError(f"{path} has no CRS; assign one explicitly before merge")
    return gdf.to_crs(dst)

Step 5 — Validate and Gate

After reprojection, assert that every layer now shares one authority code and that its bounds land in the numeric range the canonical CRS implies. A layer still reporting coordinates in the tens when the canonical CRS expects hundreds of thousands of metres never actually reprojected.

def gate(reprojected: gpd.GeoDataFrame, expected_bounds: tuple) -> None:
    xmin, ymin, xmax, ymax = reprojected.total_bounds
    exp_xmin, exp_ymin, exp_xmax, exp_ymax = expected_bounds
    assert reprojected.crs.to_authority()[1] == "25832", "CRS not canonical after reproject"
    assert exp_xmin <= xmin and xmax <= exp_xmax, "X bounds outside canonical envelope"
    assert exp_ymin <= ymin and ymax <= exp_ymax, "Y bounds outside canonical envelope"

Wire the gate into the same pull-request stage that runs your other spatial checks. The pattern for surfacing a failing gate as a review annotation is shared with automated conflict detection in merge requests, which treats a CRS mismatch as just another blocking conflict class alongside topology and attribute disputes.


Code Reliability Patterns

Never Infer a Missing CRS

The strongest reliability rule is negative: when a layer has no declared CRS, stop. Assigning a default such as EPSG:4326 because the numbers “look like degrees” is how corrupted data enters a repository. Quarantine undefined layers and require an explicit human assignment recorded in the commit.

def require_crs(gdf: gpd.GeoDataFrame, path: str) -> gpd.GeoDataFrame:
    if gdf.crs is None:
        raise ValueError(
            f"{path}: undefined CRS. Assign explicitly with set_crs(); do not guess."
        )
    return gdf

Fix Axis Order Explicitly

Because EPSG:4326 is latitude-first by definition but longitude-first in most tooling, pass always_xy=True to every transformer and reader so the convention is chosen once and never drifts. Mixing an always_xy=True writer with an always_xy=False reader silently transposes coordinates on round-trip.

Snapshot the Canonical CRS in the Repo

Store the canonical CRS as a serialized WKT2 file under version control, not just as an EPSG code. If an upstream registry ever revises a definition, the committed WKT2 remains the source of truth and your fingerprints stay comparable across time. This mirrors how other coupled metadata is pinned in the geospatial data versioning fundamentals and architecture layer.

Fail Loudly on Missing Grids

If TransformerGroup(...).best_available is False, raise rather than warn. A silent fallback to a null datum shift produces exactly the kind of ~100-metre offset that survives every downstream test until a surveyor notices.


Performance & Scale Considerations

CRS operations are cheap relative to geometry operations, but a naive gate still wastes time re-reading whole layers to inspect a header. Read only what you need.

  • Header-only CRS reads. Passing rows=1 (or using the OGR metadata API) to read a layer’s CRS avoids loading millions of geometries just to fingerprint the reference frame.
  • Cache transformer construction. Building a Transformer compiles a pipeline; reuse a single transformer for all features of a layer rather than reconstructing it per geometry.
  • Reproject in bulk. GeoPandas to_crs is vectorized over the whole GeoSeries; never loop per feature.

On a 2024 workstation (16 GB RAM, 8-core CPU), the gate’s cost is dominated by reprojection, not by CRS comparison:

Operation Dataset Time
Fingerprint (header-only) any single layer ~15 ms
Compare 50 layers vs canonical 50 layers ~0.8 s
Reproject vector layer 100 k features ~0.6 s
Reproject vector layer 2 M features ~9 s

For very large layers, reproject once at the point of merge and cache the canonical-CRS output as a derived artifact so subsequent branches compare against an already-normalized target.


Troubleshooting & Failure Modes

Symptom Root cause Fix
Coordinates in the tens vs millions between branches One branch is geographic (EPSG:4326, degrees) and the other projected (a UTM zone, metres) Fingerprint and reproject the geographic branch to the canonical projected CRS before merge
Features shifted by ~100 m after merge Datum mismatch (e.g. NAD83 vs WGS84, or an unapplied older datum) resolved with a null shift Select an explicit transformation pipeline; install PROJ grids so best_available is True
Points appear mirrored across the diagonal Axis-order conflict: two branches agree on EPSG:4326 but disagree on lat/lon order Fingerprint axis_order; pass always_xy=True consistently to every reader, writer, and transformer
Clean Git merge but layer plots in the wrong place CRS treated as opaque metadata; a branch silently reprojected without changing the label Add a content-aware CRS gate that compares declared CRS and geometry bounds, not just the text diff
to_crs raises “Cannot transform: no CRS” Source layer has an undefined CRS Quarantine and assign the correct CRS explicitly with set_crs; never guess a default
Two identical systems flagged as a conflict Same CRS expressed as EPSG code on one branch and raw WKT/PROJ on the other Normalize both to an authority code before comparing; fall back to the WKT2 fingerprint only when no code matches

FAQ

Why do two numerically-valid layers merge into corrupted data?

Because coordinate numbers carry no intrinsic reference frame. A point at (500000, 5400000) in a UTM zone and a point at (7.5, 48.7) in EPSG:4326 are both valid numbers, but stacking them treats metres and degrees as the same axis. The merge succeeds mechanically while placing features thousands of kilometres apart, and no error is raised because nothing checked what the numbers mean.

How do I detect an axis-order conflict between branches?

Compare each CRS with pyproj and inspect crs.axis_info order. EPSG:4326 is defined as latitude-longitude, but GeoJSON and many tools emit longitude-latitude. Fingerprint the axis direction alongside the authority code; if two branches agree on EPSG:4326 but disagree on axis order, their coordinates are transposed and will merge into swapped positions that look plausible until you overlay a basemap.

What causes a roughly 100-metre shift after merging?

A datum mismatch, almost always NAD83 treated as WGS84 or vice versa. The two datums differ by only one to two metres in North America today, but conflating an older NAD27 fix or skipping a required datum transformation can produce shifts of tens to well over a hundred metres. Always select an explicit transformation pipeline and confirm the high-accuracy grids are installed rather than accepting a null shift.

Should the canonical CRS be geographic or projected?

Store the canonical CRS as one authority code that fits your working extent. For area, length, and topology operations, use a metric projected CRS such as a UTM zone or a national grid to avoid the distortion a geographic CRS introduces. Reproject to a geographic CRS only for web delivery or truly global datasets where no single projection covers the extent cleanly.

Can a CRS conflict pass silently through Git?

Yes. Git and most binary spatial formats treat the CRS as opaque metadata, so a branch that silently reprojected still produces a clean textual or pointer-level merge. Only a content-aware gate that reads the declared CRS and compares geometry bounds can catch a reprojection that Git considers an ordinary change to a blob.


Back to Conflict Resolution & Team Synchronization Workflows