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.
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-7–1e-6 deg / 0.001–0.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
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
should fall below a configured bound (commonly
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.jsonunder 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_idand 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
- Bitwise coordinate comparison. Asserting
==on coordinates fails on spatially identical geometries the moment a driver rounds to 15 decimals. Always compare within an explicittied to CRS units. - Tolerance in the wrong units. A
1e-6threshold 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. - 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.
- 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.
- 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. - Silent geometry repair. A driver may auto-
make_validan 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. - 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.
Related
- Comparing GeoJSON vs Shapefile outputs in tests — driver-specific divergence and the normalization layer in depth
- Geometry Validation Patterns — validity gates that feed parity tests clean input
- Attribute & Metadata Checks — declarative schema and type-coercion rules
- Topology Rule Enforcement — DE-9IM invariants across the boundary
- Async Execution for Large Datasets — scaling parity assertions across worker pools