Verifying CRS Metadata Integrity in GeoParquet Archives

GeoParquet stores its coordinate reference system as PROJJSON inside the geo key of the Parquet file footer, and across a partitioned archive that single block is where CRS integrity quietly fails: one partition carries a full PROJJSON definition, its neighbour a legacy WKT1 string, a third a bare EPSG:4326 shorthand, and a fourth an axis order that disagrees with the coordinates it labels. This guide is for the data engineers who own a partitioned GeoParquet tree and need an automated gate that reads every footer, confirms each geo block names the intended archival CRS in the correct form, and catches the WKT1-versus-WKT2 and axis-order defects that read fine locally but corrupt a spatial join at scale. It is the columnar-format specialisation of CRS Synchronization in Pipelines under Format Conversion & Pipeline Automation, and it treats the geo block as a contract every partition must satisfy identically.

What the geo Block Must Guarantee

The GeoParquet specification requires the geo metadata to name a version, a primary_column, and per-column encoding and CRS. The CRS should be PROJJSON — a machine-readable object that pins the datum, coordinate system, and axis order unambiguously. Integrity breaks when a writer substitutes a lossy form: a WKT1 string cannot express axis order reliably, a bare EPSG:4326 shorthand defers axis order to the reader, and a null CRS means “assume longitude/latitude” — three different ways of losing the very information the archive exists to preserve. Because these defects live in the footer and not the data, a file opens and previews correctly while its CRS is wrong.

The verification therefore inspects the footer of every partition and asserts four independent properties against it — the CRS is PROJJSON, it equals the intended archival CRS, its axis order is the WKT2 longitude/latitude convention, and the geo key is present on every leaf, not just the one you sampled:

Anatomy of the GeoParquet geo block and its four integrity checks The geo metadata block in the Parquet footer with version, primary_column, encoding, and a highlighted PROJJSON crs field, connected to four checks: CRS is PROJJSON, CRS equals the archival target, axis order is longitude/latitude per WKT2, and the geo key exists on every partition. Parquet footer · “geo” key {   "version": "1.1.0",   "primary_column": "geometry",   "columns": { "geometry": {     "encoding": "WKB",     "crs": { …PROJJSON… }   } } one footer per partition file CRS is PROJJSON not a WKT1 string or bare EPSG code CRS equals archival target declared ↔ intended EPSG Axis order lon/lat (WKT2:2019) no WKT1 lat/lon ambiguity geo key on EVERY partition fan across the whole tree

Validating the geo Block Across a Partitioned Tree

The checks read footers only — never the geometry payload — so a full audit of a terabyte-scale tree costs seconds per thousand files. The gate runs after any write that could touch metadata, including the conversions described in Converting Legacy Shapefiles to GeoParquet at Scale.

Walk the partition tree and pull the geo block from each footer with pyarrow, which reads Parquet metadata without materialising a single row group. Assert the required keys exist before inspecting the CRS — a missing geo key or absent primary_column is a hard structural failure.

import json, pathlib
import pyarrow.parquet as pq

TREE = pathlib.Path("datasets/vector/archive")
findings = []
for path in TREE.rglob("*.parquet"):
    md = pq.read_metadata(path)
    if b"geo" not in md.metadata:
        findings.append({"file": str(path), "error": "MISSING_GEO_KEY"})
        continue
    geo = json.loads(md.metadata[b"geo"])
    col = geo.get("primary_column")
    if not col or col not in geo.get("columns", {}):
        findings.append({"file": str(path), "error": "NO_PRIMARY_COLUMN"})
        continue
    findings.append({"file": str(path), "geo": geo["columns"][col]})

Phase 2: Assert PROJJSON Form and Target Equality

Each surviving crs value must be PROJJSON — a JSON object — and must equal the archival target when parsed. A string value signals a legacy WKT1 or bare-code substitution; a None value signals the implicit-lon/lat default. Both fail. Use pyproj.CRS.equals for the comparison so EPSG:4326, its WKT2 form, and its PROJJSON compare equal by identity rather than by text.

import pyproj

TARGET = pyproj.CRS.from_epsg(4326)

def check_crs(entry):
    crs = entry.get("crs")
    if crs is None:
        return "NULL_CRS_IMPLICIT_LONLAT"
    if not isinstance(crs, dict):
        return "NOT_PROJJSON"                      # WKT1 string or "EPSG:xxxx" shorthand
    parsed = pyproj.CRS.from_json_dict(crs)
    if not parsed.equals(TARGET):
        return f"WRONG_CRS:{parsed.to_epsg()}"
    return "OK"

Phase 3: Catch Axis-Order and WKT1-vs-WKT2 Defects

Axis order is the defect PROJJSON exists to prevent and WKT1 cannot express. Inspect the coordinate-system axes in the PROJJSON and confirm the first axis is easting/longitude, then cross-check against the actual coordinate envelope: if the declared order says lon/lat but the sampled xmin behaves like a latitude, the geometry was written transposed.

import duckdb

def axis_order_ok(crs_dict):
    axes = crs_dict.get("coordinate_system", {}).get("axis", [])
    if not axes:
        return False
    first = axes[0].get("direction")               # "east"/"north" or geographic dir
    return first in ("east", "geodeticEast")

con = duckdb.connect(); con.execute("INSTALL spatial; LOAD spatial;")
def envelope_matches_lonlat(path):
    xmin, ymin = con.execute(f"""
        SELECT min(ST_XMin(geom)), min(ST_YMin(geom))
        FROM (SELECT geom FROM ST_Read('{path}') USING SAMPLE 2000 ROWS)
    """).fetchone()
    # Longitude range is wider than latitude; a swapped file inverts this.
    return abs(xmin) <= 180 and abs(ymin) <= 90

Phase 4: Fan the Gate Across the Whole Tree

Combine the checks into one pass and fail the archive if a single partition disagrees. A tree is only trustworthy when uniform: a query engine that reads the geo block from the first file it opens will apply that CRS to the whole dataset, so one deviant partition mislabels everything read after it.

import sys

verdicts = {}
for f in findings:
    if "error" in f:
        verdicts[f["file"]] = f["error"]; continue
    crs = check_crs(f["geo"])
    axis = "OK" if axis_order_ok(f["geo"].get("crs", {})) else "BAD_AXIS_ORDER"
    verdicts[f["file"]] = crs if crs != "OK" else axis

failures = {k: v for k, v in verdicts.items() if v != "OK"}
json.dump(verdicts, open("geo_integrity_report.json", "w"), indent=2)
sys.exit(1 if failures else 0)                     # non-zero blocks promotion in CI

Confirming the Gate on a Sample Partition

Spot-check any partition with the DuckDB Parquet reader to read the raw footer key, and confirm the crs renders as a PROJJSON object naming the target authority.

duckdb -c "SELECT decode(value) FROM parquet_kv_metadata(
  'datasets/vector/archive/region=north/part-0007.parquet') WHERE decode(key) = 'geo'"

Annotated expected output — the crs is a nested object (PROJJSON), not a string, and its id names the target authority:

{"version":"1.1.0","primary_column":"geometry","columns":{"geometry":{
  "encoding":"WKB","geometry_types":["MultiPolygon"],
  "crs":{"type":"GeographicCRS","name":"WGS 84",
         "coordinate_system":{"axis":[{"direction":"east"},{"direction":"north"}]},
         "id":{"authority":"EPSG","code":4326}}}}}

If the crs prints as "EPSG:4326" in quotes or is absent, the writer emitted a lossy form and the partition fails the PROJJSON check even though pyproj can still parse it — the archive standard is the full object, because only it survives a future reader that does not share your EPSG database. Record the passing CRS form in the Metadata Cataloging & Discovery index so the catalog and the footer never disagree.

Troubleshooting

Symptom Root cause Diagnostic & fix
crs prints as a quoted "EPSG:4326" string Writer stored the shorthand instead of PROJJSON Rewrite the footer with table.replace_schema_metadata, injecting pyproj.CRS(...).to_json_dict()
Some partitions pass, others report MISSING_GEO_KEY Non-spatial writer (plain pyarrow/pandas) touched part of the tree Re-emit affected partitions through a GeoParquet writer; add the gate to CI so it cannot recur
BAD_AXIS_ORDER with a valid target CRS WKT1 lineage or a lat/lon writer under a lon/lat CRS Re-encode PROJJSON with explicit east/north axes; verify the envelope with the DuckDB sample
Gate passes locally, fails in the reader Reader took the CRS from the first footer; a later partition differs Enforce tree-wide uniformity; block promotion on any single non-OK verdict

Operational Execution Checklist

Part of the Spatial Data Archival knowledge base.