Cross-Format Parity Testing

Cross-format parity testing is the round-trip assertion pattern within Spatial Test Pattern Design & Implementation that guarantees geometric, topological, and attribute fidelity when a dataset crosses a format boundary. Every time a feature collection is serialized from one interchange format to another — GeoJSON to Shapefile, GeoPackage to FlatGeobuf, PostGIS to file — driver-specific encoding rules silently rewrite coordinates, truncate field names, coerce types, and repair geometry. A parity test makes those rewrites observable: it writes a known input, reads it back, and asserts that the reconstructed dataset is equivalent to the original under an explicit tolerance model. Without this gate, format-induced loss reaches downstream consumers as corrupt spatial joins, broken routing graphs, and inaccurate regulatory reporting that no upstream schema check would ever catch.

This page defines the parity assertion family, its tolerance strategy, runnable pytest and PostGIS implementations, and the CI integration that turns it into a hard merge gate. It sits one level below the core geospatial QA architecture, and it composes with the same strict tolerance thresholds you configure for every other geometric assertion.

Cross-format parity testing decision flow A source dataset is written to a target format and read back in a round-trip loop, normalized for CRS and coordinate precision, then split into four parity check families — geometric, attribute and schema, topological, and metadata and encoding. Their results converge on a single tolerance gate whose outcome either allows the merge or hard-blocks it on geometry or topology drift. round-trip: write → read Source dataset GeoJSON · GPKG · PostGIS Target format driver serialize Normalize CRS + precision Geometric parity vertex ε · Hausdorff Attribute / schema names · types · values Topological parity validity · DE-9IM Metadata / encoding EPSG · UTF-8 Tolerance gate Pass → merge within thresholds Block merge geometry / topology drift write read back

Parity Assertion Taxonomy

A parity test is never a single equality check. It decomposes into four assertion families, each with its own tolerance strategy and threshold range. Conflating them produces unactionable failures: a report that says “datasets differ” tells an engineer nothing, whereas a report scoped to “attribute truncation in field population_density” points straight at the driver limitation.

Assertion family What it asserts Tolerance strategy Typical threshold Applicable CRS units
Geometric parity Vertex coordinates and structure survive serialization Absolute coordinate epsilon, snap-to-grid 1e-71e-6 deg / 0.0010.01 m Angular (deg), projected (m)
Attribute / schema parity Field names, types, and values map without loss Exact match with documented exception list Exact, or 1e-9 relative for floats Unit-independent
Topological parity Validity and DE-9IM relationships are invariant Predicate equality after make_valid Boolean (must hold) Unit-independent
Metadata / encoding parity CRS, encoding, and layer metadata preserved Exact normalized comparison Exact (EPSG code, UTF-8) Unit-independent

The geometric tolerance is the only family that demands a numeric threshold tied to CRS units. For an angular dataset in EPSG:4326 a tolerance ε\varepsilon of 10610^{-6} degrees bounds positional error to roughly 0.11 m at the equator; for a projected CRS in metres you set ε\varepsilon directly in survey units. The other families are categorical and should be asserted exactly, with a documented, version-controlled exception list for known driver behavior (Shapefile field truncation is expected, not a failure).

Geometric Parity

Geometric parity verifies that the coordinate sequence and structural composition of each feature survive the round trip. The naive trap is bitwise coordinate equality: GDAL and OGR drivers round, reorder, and re-encode coordinates, so an exact comparison fails on geometries that are spatially identical. The correct predicate is tolerance-aware equality, expressed in GeoPandas through geom_equals_exact, which compares vertex-by-vertex within an absolute epsilon:

# tolerance-aware vertex comparison (GeoPandas 0.14+, Shapely 2.x)
equal = src.geometry.geom_equals_exact(tgt.geometry, tolerance=1e-6)

Where vertex ordering may legitimately differ (driver-flipped ring orientation, reordered multi-parts), fall back to a set-theoretic predicate: two geometries are equivalent when their symmetric difference has negligible area. The relative area delta

δA=AsrcAtgtmax(Asrc,Atgt)\delta_A = \frac{\lvert A_{\text{src}} - A_{\text{tgt}} \rvert}{\max(A_{\text{src}},\, A_{\text{tgt}})}

should fall below a configured bound (commonly 10910^{-9}). For positional worst-case error rather than averaged area, the directed Hausdorff distance

dH(X,Y)=maxxXminyYxyd_H(X, Y) = \max_{x \in X} \min_{y \in Y} \lVert x - y \rVert

bounds the largest single-vertex drift introduced by the format conversion and is the most defensible metric for survey-grade gating.

Attribute and Schema Parity

Attribute serialization is where formats diverge most aggressively. Shapefiles truncate field names to ten characters, cap string fields at 254 bytes, lack native NULL, and coerce mixed-type columns; GeoJSON preserves full JSON typing, nested objects, and explicit null. A parity test must therefore compare the logical schema, not the raw one, applying a documented mapping before assertion. This is the boundary where parity testing hands off to attribute and metadata checks, which own the declarative type-coercion and domain-constraint rules:

# normalize field names to the lowest-common-denominator schema before comparing
def harmonize_columns(gdf, max_len=10):
    gdf.columns = [c[:max_len].lower() for c in gdf.columns]
    return gdf

Float-valued attributes need the same relative-error treatment as coordinates — assert numpy.isclose(a, b, rtol=1e-9) rather than == — while integer, categorical, and temporal columns assert exactly. The driver-specific limitations that justify each exception are documented in the GDAL Vector Driver Documentation, and parity assertions must distinguish intentional schema normalization from unintended loss.

Topological Parity

Geometric closeness does not guarantee spatial correctness. A conversion can stay inside the coordinate tolerance yet introduce sliver polygons, dangling nodes, or broken adjacency — defects that corrupt overlays and network routing while passing a vertex comparison. Topological parity asserts that validity and the DE-9IM relationship matrix are invariant across the boundary, which is exactly the contract that topology rule enforcement defines and that geometry validation patterns feed with valid input:

from shapely.validation import make_valid
# validity must be preserved, not silently repaired by the driver
assert make_valid(src_geom).equals(make_valid(tgt_geom))
assert src_geom.relate(neighbor) == tgt_geom.relate(neighbor_tgt)  # DE-9IM invariant

Running validity before parity assertions keeps the signal clean: malformed rings caught at ingestion never produce false-positive parity failures from a driver’s automatic geometry repair.

Metadata and Encoding Parity

The fourth family covers the metadata that travels alongside geometry: the CRS declaration, character encoding, and layer-level attributes. GeoJSON mandates EPSG:4326 and UTF-8 per RFC 7946; Shapefiles externalize the CRS into a .prj sidecar and historically fall back to system encodings such as CP1252, corrupting non-ASCII attribute strings on round trip. Parity here is an exact comparison of normalized values — the EPSG authority code, not the WKT string (which drivers rewrite verbatim while preserving meaning), and an explicit encoding="utf-8" on every read and write. Coordinate encoding and rounding behavior should be reconciled against the OGC Simple Feature Access Standard, which governs how coordinate sequences are serialized across conforming drivers.

Production-Grade Python Implementation

The canonical parity test is a pytest case that loads a fixture, performs a round trip through the driver under test, and runs all four assertion families with thresholds loaded from a version-controlled config rather than hard-coded inside the test. Parameterizing over target formats turns one test body into a matrix that gates every interchange path the pipeline emits.

# test_cross_format_parity.py — pytest 7+, GeoPandas 0.14+, Shapely 2.x
import json
from pathlib import Path

import geopandas as gpd
import numpy as np
import pytest
from shapely.validation import make_valid

# thresholds externalized to parity_config.json, never inlined
CFG = json.loads(Path("parity_config.json").read_text())
GEOM_TOL = CFG["geometric"]["xy_tolerance"]      # e.g. 1e-6 (degrees)
AREA_RTOL = CFG["geometric"]["area_rtol"]        # e.g. 1e-9
ATTR_RTOL = CFG["attribute"]["float_rtol"]       # e.g. 1e-9


def round_trip(src: gpd.GeoDataFrame, target: Path, driver: str) -> gpd.GeoDataFrame:
    """Write to the target format and re-read — isolates serialization artifacts."""
    src.to_file(target, driver=driver, encoding="utf-8")
    return gpd.read_file(target)


@pytest.mark.parametrize(
    "driver,suffix",
    [("GeoJSON", ".geojson"), ("GPKG", ".gpkg"), ("FlatGeobuf", ".fgb")],
)
def test_format_parity(tmp_path, driver, suffix):
    src = gpd.read_file("fixtures/golden_features.gpkg")
    src["geometry"] = src.geometry.apply(make_valid)

    tgt = round_trip(src, tmp_path / f"out{suffix}", driver)
    assert tgt.crs.to_epsg() == src.crs.to_epsg(), "metadata parity: CRS drifted"

    # geometric parity — tolerance-aware vertex comparison
    equal = src.geometry.geom_equals_exact(tgt.geometry, tolerance=GEOM_TOL)
    if not equal.all():
        # fall back to area-delta for legitimate vertex reordering
        delta = (src.area - tgt.area).abs() / np.maximum(src.area, tgt.area)
        assert (delta < AREA_RTOL).all(), f"geometric parity failed: {delta.max():.2e}"

    # attribute parity — float columns within rtol, others exact
    for col in src.columns.drop("geometry"):
        s, t = src[col], tgt[col[:10].lower()]  # respect driver name truncation
        if np.issubdtype(s.dtype, np.floating):
            assert np.allclose(s, t, rtol=ATTR_RTOL, equal_nan=True)
        else:
            assert (s.values == t.values).all(), f"attribute parity failed: {col}"

    # topological parity — validity preserved, not silently repaired
    assert tgt.geometry.is_valid.all(), "topological parity: driver introduced invalidity"

The test isolates serialization artifacts from upstream ETL defects because it begins from a golden fixture, not pipeline output. Source artifacts are opened read-only, intermediate writes are staged in tmp_path (or a virtual file system such as /vsizip/ for archive round trips), and the driver runs inside a container with pinned libgeos/PROJ binaries so the same vertices serialize identically across CI runners.

PostGIS and Database-Side Counterparts

When the format boundary is a database rather than a file — loading a Shapefile into PostGIS with shp2pgsql, or exporting a table with ogr2ogr — the parity assertion moves server-side. PostGIS exposes the same predicates as native functions, which lets you gate large tables without pulling every geometry into Python:

-- geometric parity within tolerance, expressed as a count of drifted rows
SELECT count(*) AS drifted
FROM   source_features s
JOIN   roundtrip_features r USING (feature_id)
WHERE  NOT ST_DWithin(s.geom, r.geom, 1e-6)         -- positional tolerance
   OR  NOT ST_Equals(ST_SnapToGrid(s.geom, 1e-6),
                     ST_SnapToGrid(r.geom, 1e-6));   -- structural equality

-- topological parity: validity must survive the load
SELECT feature_id, ST_IsValidReason(geom)
FROM   roundtrip_features
WHERE  NOT ST_IsValid(geom);

Driving these from psycopg2 lets the parity stage assert across millions of rows inside the database engine and return only the failing feature_id set as a diff manifest. For tables that exceed a single worker’s memory or transaction budget, this server-side comparison composes with async execution for large datasets, which chunks the join across bounded-concurrency workers while propagating the same tolerance values.

Pipeline Integration and CI Gating

Parity testing earns its value only as an automated stage, not an ad-hoc audit. Slot it into the merge pipeline so it triggers on the events that actually change serialization behavior: schema changes, driver or GDAL/PROJ version bumps, and CRS migrations. Three integration rules keep it deterministic:

  • Container pinning. Run the parity stage in an image with fixed libgeos, PROJ, and GDAL versions. An unpinned runner upgrades GDAL underneath you and turns a passing gate into an unexplained coordinate-drift failure.
  • Config-driven thresholds. Tolerances live in parity_config.json under version control, so QA can tighten a survey-grade gate without editing test logic — and every threshold change is reviewable in the diff.
  • Structured diff manifests. On failure, emit a machine-readable report keyed by feature_id and assertion family, listing the exact coordinate drift, truncated attribute, or invalidity reason. Cache artifacts by immutable content hash so identical inputs skip re-validation.
# parity gate in CI — fail-fast, hard block on geometry/topology divergence
parity:
  image: ghcr.io/org/gis-runner:gdal-3.9-proj-9.4   # pinned toolchain
  script:
    - pytest test_cross_format_parity.py --junitxml=parity.xml
  artifacts:
    when: on_failure
    paths: [parity_diff.json]

Geometry and topology divergence are hard gates that block the merge; metadata-only differences (a rewritten but equivalent CRS WKT string) can be downgraded to warnings. This mirrors the fail-fast posture the rest of the pattern set uses and produces the audit trail downstream consumers rely on.

Common Failure Modes and Gotchas

  1. Bitwise coordinate comparison. Asserting == on coordinates fails on spatially identical geometries the moment a driver rounds to 15 decimals. Always compare within an explicit ε\varepsilon tied to CRS units.
  2. Tolerance in the wrong units. A 1e-6 threshold means 0.11 m in EPSG:4326 but 1 micrometre in a metre-based projected CRS. Reproject to a known CRS before applying a numeric tolerance, or maintain per-CRS thresholds.
  3. Treating Shapefile truncation as a failure. Ten-character field names and 254-byte strings are documented driver behavior. Encode them in an exception list; flag only undocumented loss.
  4. Anti-meridian splitting. GeoJSON exporters split geometries crossing ±180° longitude into multi-parts per RFC 7946, changing the part count without changing the shape. Test with an anti-meridian fixture and assert on dissolved area, not part count.
  5. Encoding fallback corruption. Omitting encoding="utf-8" lets a Shapefile round trip through CP1252 and mangle non-ASCII attributes. Force UTF-8 on every read and write and assert string equality on a fixture containing diacritics.
  6. Silent geometry repair. A driver may auto-make_valid an invalid input, so the round trip “passes” while the geometry has been altered. Validate the source first and assert that validity status is preserved, not improved.
  7. Empty and null geometry handling. Shapefiles drop null geometries; GeoJSON keeps them as null. A row-count mismatch after round trip is the tell — assert feature counts before per-feature comparison.

Conclusion

Cross-format parity testing converts the silent, driver-dependent rewrites of serialization into deterministic, tolerance-gated assertions across four families — geometric, attribute, topological, and metadata. Implemented as a pinned, config-driven CI stage with structured diff manifests, it stops format-induced loss at the merge boundary instead of in production maps. For the full set of patterns this gate composes with, return to Spatial Test Pattern Design & Implementation.