Preserving CRS Metadata in GeoParquet

The reliable way to keep a coordinate reference system intact through a GeoParquet conversion is to serialise the CRS as PROJJSON and embed it under the geometry column in the file’s geo metadata key — not to lean on a bare EPSG:4326 string or hope the writer carries it across. PROJJSON is the representation the GeoParquet specification recommends: it is unambiguous, fully self-describing, and survives round-trips through GeoPandas and pyproj without datum-shift surprises. CRS loss is uniquely dangerous because it is silent — a file with dropped CRS metadata opens without error and plots every feature in the wrong place. This page shows how to write the geo metadata correctly, validate it by rebuilding the CRS with pyproj, and gate against loss. It sits under Preserving Metadata During GeoParquet Conversion, which covers the wider metadata-fidelity problem.

Quick-Reference: CRS Representations

Representation Ambiguity risk Where it belongs Primary Use Case
PROJJSON Lowest — full self-contained definition GeoParquet geo metadata (recommended) Production conversion output for any consumer
WKT2 string Low — verbose but complete Interchange with GDAL/OGC tooling Handoff to non-Python spatial stacks
EPSG authority code Medium — depends on reader’s database version Compact human-facing labels Logs, dashboards, quick identification
Bare CRS string / none High — datum and axis order undefined Nowhere in production Only transient in-memory before serialisation

Where CRS Lives in GeoParquet

GeoParquet stores spatial metadata in a single Parquet key-value entry named geo, whose value is a JSON document. Inside it, each geometry column has an entry that records the encoding, the geometry types present, the bounding box, and — critically — the crs. The specification recommends that crs hold a PROJJSON object. This matters because the alternatives each carry a failure mode. A bare EPSG code like "EPSG:27700" is compact but forces every reader to resolve it against its own PROJ database, and subtle datum-transformation defaults have changed across PROJ releases, so two readers can place the same code slightly differently. Raw WKT2 is complete but notoriously fiddly to parse consistently. PROJJSON side-steps both: it ships the whole definition — datum, ellipsoid, axis order, units — as structured JSON that any conformant reader interprets identically. When you also need to keep application metadata alongside the CRS, the same discipline applies as in preserving QGIS metadata in FlatGeobuf: treat the metadata block as a first-class output, not a side effect.

The most common way CRS is lost is that a low-level writer serialises the geometry column as plain WKB and never emits the geo key at all. GeoPandas’ to_parquet writes the geo metadata for you, but hand-rolled PyArrow writers, some Spark connectors, and naive pyarrow.parquet.write_table calls do not — they produce a technically valid Parquet file with a binary geometry column and no CRS anywhere. Readers then fall back to an undefined CRS. The second failure is subtler: the geo key exists but its crs is null because the source dataset itself had an undefined CRS that passed through unchecked. Both cases plot correctly-shaped geometry in the wrong location, which is why a conversion should refuse to publish a file whose CRS is missing or null. This is exactly the kind of invariant a CI/CD validation gate should enforce on every file.

Round-tripping is the proof that the write worked. After conversion, read the file back, rebuild the CRS from the recovered PROJJSON with pyproj, and compare it to the intended target by object identity rather than string equality. This catches the whole class of “looks right, is wrong” bugs — a file that stored EPSG:4326 when the coordinates were actually in a projected national grid, or a reprojection that silently occurred mid-pipeline. The comparison must go through pyproj.CRS because "EPSG:4326", 4326, and the equivalent WKT2 are all the same CRS expressed three ways, and only semantic comparison treats them as equal.

CRS preservation and round-trip through GeoParquet A source CRS is converted to PROJJSON, embedded in the GeoParquet geo metadata, read back, rebuilt with pyproj, and compared for equality against the target CRS. Source CRS pyproj.CRS PROJJSON serialise GeoParquet geo → columns → crs Read back rebuild CRS CRS == target? Compare with pyproj equality, never string match.

Writing CRS as PROJJSON

The writer below resolves the source CRS with pyproj, and GeoPandas serialises it into the geo metadata as PROJJSON on write. The explicit CRS assignment guards against a source that arrived with an undefined CRS.

python
# Requires: geopandas>=0.14, pyproj>=3.6, pyarrow>=14.0, shapely>=2.0
from __future__ import annotations

import json
import logging

import geopandas as gpd
import pyarrow.parquet as pq
from pyproj import CRS

logger = logging.getLogger(__name__)


def write_with_crs(
    gdf: gpd.GeoDataFrame,
    output_path: str,
    *,
    target_epsg: int,
    compression: str = "zstd",
) -> None:
    """Write a GeoParquet file with an explicit, validated CRS.

    Raises if the frame has no CRS and none can be assumed safely, so a
    file with silent CRS loss is never produced.
    """
    target = CRS.from_epsg(target_epsg)

    if gdf.crs is None:
        raise ValueError(
            "GeoDataFrame has no CRS; refusing to write to avoid silent loss. "
            "Set gdf.set_crs(...) explicitly if the source CRS is known."
        )
    if CRS.from_user_input(gdf.crs) != target:
        # Reproject deliberately rather than mislabel the coordinates.
        gdf = gdf.to_crs(target)
        logger.info("Reprojected to EPSG:%d before write", target_epsg)

    # GeoPandas serialises gdf.crs into the GeoParquet 'geo' metadata as PROJJSON.
    gdf.to_parquet(output_path, compression=compression, index=False)
    logger.info("Wrote GeoParquet with CRS EPSG:%d → %s", target_epsg, output_path)


def read_crs_from_geoparquet(path: str) -> CRS | None:
    """Recover the CRS from a GeoParquet file's geo metadata via pyproj."""
    meta = pq.ParquetFile(path).schema_arrow.metadata or {}
    geo_raw = meta.get(b"geo")
    if geo_raw is None:
        return None
    geo = json.loads(geo_raw)
    primary = geo["primary_column"]
    crs_obj = geo["columns"][primary].get("crs")
    return CRS.from_user_input(crs_obj) if crs_obj is not None else None

Validation

Prove the CRS survived by rebuilding it from the written file and comparing it to the target with pyproj equality:

python
# Requires: pyproj>=3.6, pyarrow>=14.0
from pyproj import CRS

def assert_crs_preserved(path: str, target_epsg: int) -> None:
    recovered = read_crs_from_geoparquet(path)   # from the writer module above
    if recovered is None:
        raise AssertionError(f"{path}: no CRS in geo metadata (CRS lost)")
    if recovered != CRS.from_epsg(target_epsg):
        raise AssertionError(
            f"{path}: CRS is {recovered.to_epsg()} not EPSG:{target_epsg}"
        )

Expected outcomes: a correctly written file returns a non-null CRS whose pyproj object equals CRS.from_epsg(target_epsg), and recovered.to_epsg() echoes the target code. Inspect the raw metadata on the command line with python -c "import pyarrow.parquet as pq, json; print(json.loads(pq.ParquetFile('out.parquet').schema_arrow.metadata[b'geo'])['columns'])" and confirm the crs field holds a PROJJSON object (a JSON dict with a type key), not a bare string or null. A null CRS or a missing geo key is a hard failure that must block publication.

Edge Cases and Caveats

Source datasets with a genuinely undefined CRS. Some legacy Shapefiles ship with an empty or missing .prj. Do not guess EPSG:4326 — assign the CRS explicitly from an authoritative source and record that you did so, because a wrong assumed CRS is worse than a null one that a reader is forced to question.

Axis order surprises. EPSG:4326 is defined as latitude-longitude, but most GeoParquet data stores coordinates longitude-latitude. GeoParquet assumes longitude-latitude for geographic CRS by convention; if your PROJJSON and your coordinate order disagree, downstream tools will transpose points. Keep coordinates longitude-latitude and let the CRS describe the datum, not the axis order.

Custom and compound CRS. A locally defined or compound (horizontal + vertical) CRS has no EPSG code, so an EPSG-only pipeline drops it. PROJJSON handles these cases natively — another reason to serialise the full definition rather than a code. Confirm your readers accept compound CRS if you carry vertical datums.

Frequently Asked Questions

Should GeoParquet store CRS as EPSG code, WKT2, or PROJJSON?

PROJJSON. The GeoParquet specification recommends storing the CRS as PROJJSON inside the geo metadata because it is unambiguous, machine-parseable, and carries the full definition rather than a code a reader might resolve against a different database version. A bare EPSG code risks datum-shift ambiguity across pyproj versions, and raw WKT2 is valid but harder to parse reliably than PROJJSON.

Why does my GeoParquet file open with no CRS after conversion?

The converter wrote geometry but dropped the geo metadata, or wrote the CRS as null. This happens when a source with an undefined CRS passes through unchecked, or when a low-level writer serialises the geometry column without the GeoParquet geo key. Readers then default to an unknown CRS and plot the coordinates in the wrong place. Always assert the geo key is present and its CRS is non-null before publishing.

How do I compare two CRS definitions reliably in a validation check?

Rebuild both as pyproj CRS objects and compare them with equality, never as strings. EPSG:4326, its authority code, and a full WKT2 string can all describe the same CRS while differing byte-for-byte. pyproj normalises the definitions and compares them semantically, so an identical CRS expressed two different ways is correctly reported as equal.

← Back to Preserving Metadata During GeoParquet Conversion