Preserving Metadata During GeoParquet Conversion
Modern geospatial analytics demand columnar storage for performance, yet migrating legacy vector datasets to GeoParquet frequently strips critical provenance, coordinate reference system (CRS) definitions, and custom attributes. Unlike shapefiles or GeoJSON, which embed metadata in sidecar files or inline JSON, GeoParquet relies on Parquet’s file-footer metadata and a strict geo JSON schema. Naïve conversions drop everything outside geometry and tabular columns, breaking downstream lineage tracking, spatial indexing, and regulatory compliance. This guide provides a production-tested workflow for extracting, mapping, and injecting metadata during format transitions, ensuring full alignment with the GeoParquet 1.0.0 specification while keeping your pipelines reliable.
This page is part of the Data Conversion & Migration Pipelines guide, which covers the full arc from batch orchestration to fallback routing.
Prerequisites
A reliable conversion stack requires modern, actively maintained libraries that expose Parquet metadata APIs:
- Python 3.9+ (type hints and
pathlibrequired for pipeline orchestration) pyarrow>=14.0.0(direct Parquet metadata manipulation and schema enforcement)geopandas>=1.0.0(spatial DataFrame operations and CRS normalisation)pyogrio>=0.7.0(GDAL-backed I/O with optimised vector reading)shapely>=2.0.0(geometry validation and topology checks)pandas>=2.0.0(tabular type coercion and null handling)
Install in one step:
pip install pyarrow geopandas pyogrio shapely pandas
For cloud deployments, confirm your IAM roles grant s3:PutObject and s3:GetObject permissions before writing directly to object storage. Profile a representative sample of your source data before starting — file sizes, CRS diversity, and attribute cardinality all affect how you configure each stage below.
Architectural Foundations
Traditional GIS formats store metadata heterogeneously. GeoPackage uses SQLite tables, Shapefile’s split-file design relies on .prj and .xml sidecars, and FlatGeobuf packs metadata in a header block. Parquet, designed for analytical query performance, stores metadata as key-value pairs in the file footer. The GeoParquet standard bridges this gap by reserving a geo key containing a JSON object that defines geometry columns, CRS, bounding boxes, and encoding. If you are still deciding between the two columnar-friendly vector formats, the GeoParquet vs FlatGeobuf performance comparison covers the read-path trade-offs that motivate choosing Parquet as your analytical target here.
When designing data conversion and migration pipelines, engineers must account for three distinct metadata layers:
- Geospatial Core — CRS, geometry type, bounding box, and primary geometry column name.
- Schema & Type Mapping — precision, scale, nullability constraints, and temporal formats.
- Custom/Provenance — source system identifiers, processing timestamps, and domain-specific tags.
Failing to serialise these layers correctly leads to silent data degradation. Some libraries quietly drop unrecognised keys; others raise exceptions on malformed CRS strings. Neither failure mode is acceptable in a production data lake where downstream consumers trust the footer as ground truth.
The reason this architecture works is that Parquet’s footer is loaded once at file-open time and cached in memory, making it an efficient sidecar for spatial metadata. The geo key is a first-class citizen of the GeoParquet specification, not a workaround. Engines like DuckDB’s spatial extension, Apache Sedona, and GDAL/OGR all read this key before dispatching any spatial query, so getting it right unlocks predicate pushdown and native spatial filtering without any extra configuration.
The diagram below shows the required structure of the geo footer JSON. Every field shown is mandatory for full cross-engine compatibility — omitting bbox or geometry_types causes silent fallback in GDAL and query-plan degradation in DuckDB:
Step-by-Step Workflow
Step 1 — Profile Your Source Data
Before touching any code, run pyogrio.read_info() against the source dataset to understand the CRS, geometry type distribution, and bounding box. This five-minute step prevents surprises during conversion:
# pyogrio>=0.7.0
import pyogrio
from pathlib import Path
source_path = Path("input/boundaries.gpkg")
info = pyogrio.read_info(str(source_path))
print(info)
# {'crs': 'EPSG:4326', 'encoding': 'UTF-8', 'geometry_type': 'MultiPolygon',
# 'total_bounds': (-180.0, -90.0, 180.0, 90.0), 'features': 245000}
Pay attention to:
geometry_type: mixed geometry collections require normalisation before writing.total_bounds: verify these are plausible — corrupt.prjfiles can produce bounding boxes at the origin.crs: anything that is not a clean EPSG code needs manual resolution before conversion.
Step 2 — Extract Source Metadata and CRS Definitions
Once you have profiled the dataset, capture all embedded metadata in a structured dictionary. Many legacy formats store CRS information in non-standard locations, requiring careful parsing:
import pyogrio # >=0.7.0
from pathlib import Path
from typing import Any
def extract_source_metadata(source_path: Path) -> dict[str, Any]:
"""Extract CRS, bounding box, and geometry metadata from source vector.
Returns a normalised dict safe to pass to build_geoparquet_metadata().
Raises ValueError if the file cannot be opened or has no CRS.
"""
try:
meta = pyogrio.read_info(str(source_path))
except Exception as exc:
raise ValueError(f"Cannot read source metadata from {source_path}: {exc}") from exc
crs_raw: str = meta.get("crs") or ""
if "EPSG:" in crs_raw:
crs_epsg = crs_raw.split("EPSG:")[-1].split("]")[0].strip()
elif crs_raw.lstrip("-").isdigit():
crs_epsg = crs_raw.strip()
else:
# Unknown CRS — quarantine rather than inject a silent default
crs_epsg = "unknown"
return {
"crs": crs_epsg,
"bbox": meta.get("total_bounds"),
"geometry_type": meta.get("geometry_type", "Unknown"),
"encoding": meta.get("encoding", "UTF-8"),
"feature_count": meta.get("features"),
}
Unknown CRS values should be flagged for manual review, not silently replaced with EPSG:4326. A silent default breaks every downstream spatial join against a correctly projected reference layer.
Step 3 — Map and Coerce Schema Types
Parquet’s strict typing conflicts with the loosely typed nature of many GIS formats. Integer fields stored as strings, mixed-precision floats, and timezone-aware datetime objects must be explicitly coerced before serialisation. Column names that contain spaces, special characters, or reserved keywords also need standardisation — downstream engines like DuckDB or AWS Athena will reject or silently mishandle them:
import geopandas as gpd # >=1.0.0
import pandas as pd # >=2.0.0
import pyarrow as pa # >=14.0.0
import re
def sanitise_column_name(name: str) -> str:
"""Replace non-alphanumeric characters with underscores; lowercase."""
return re.sub(r"[^a-z0-9_]", "_", name.lower()).strip("_")
def map_and_coerce_schema(gdf: gpd.GeoDataFrame) -> pa.Table:
"""Normalise types and prepare a GeoDataFrame for Parquet serialisation.
- Renames columns to safe identifiers.
- Coerces timezone-aware datetimes to UTC-naive millisecond precision.
- Replaces empty strings with NA to avoid Arrow type inference failures.
- Preserves geometry as a WKB binary column named 'geometry'.
Returns a pyarrow.Table ready for footer injection.
"""
gdf = gdf.copy()
geom_col = gdf.geometry.name
# Rename non-geometry columns to safe identifiers
rename_map = {
col: sanitise_column_name(col)
for col in gdf.columns
if col != geom_col and col != sanitise_column_name(col)
}
if rename_map:
gdf = gdf.rename(columns=rename_map)
for col in gdf.columns:
if col == geom_col:
continue
if pd.api.types.is_datetime64_any_dtype(gdf[col]):
gdf[col] = gdf[col].dt.tz_localize(None).astype("datetime64[ms]")
gdf = gdf.replace("", pd.NA)
return pa.Table.from_pandas(gdf, preserve_index=False)
Refer to schema mapping for legacy to modern formats for comprehensive naming conventions and type coercion matrices covering edge cases like mixed-type columns and UTF-8 encoding conflicts.
Step 4 — Construct the GeoParquet Footer
GeoParquet requires a specific JSON structure attached to the geo key in the Parquet file metadata. This structure must conform to the specification exactly, or spatial engines will ignore the geometry column entirely. The PROJJSON CRS object is the most common source of errors — even a minor structural deviation causes silent fallback to plain binary:
import json
import pyarrow as pa # >=14.0.0
from typing import Any
def build_geoparquet_metadata(
table: pa.Table,
source_meta: dict[str, Any],
primary_column: str = "geometry",
) -> pa.Table:
"""Attach compliant GeoParquet 1.0.0 metadata to the Arrow table.
Constructs a minimal PROJJSON CRS object from the EPSG code extracted
during Step 2. Falls back to EPSG:4326 only if the code is truly unknown
AND the caller explicitly accepts that default by passing crs='unknown'.
Raises ValueError if the primary_column is absent from the table schema.
"""
if primary_column not in table.schema.names:
raise ValueError(
f"Primary geometry column '{primary_column}' not found in table schema. "
f"Available columns: {table.schema.names}"
)
crs_code = source_meta.get("crs", "unknown")
try:
epsg_int = int(crs_code)
except (ValueError, TypeError):
epsg_int = 4326 # last-resort default; log a warning in production
bbox = source_meta.get("bbox")
bbox_list: list[float] | None = list(bbox) if bbox is not None else None
geo_meta: dict[str, Any] = {
"version": "1.0.0",
"primary_column": primary_column,
"columns": {
primary_column: {
"encoding": "WKB",
"geometry_types": [source_meta.get("geometry_type", "Unknown")],
"crs": {
"$schema": "https://proj.org/schemas/v0.7/projjson.schema.json",
"type": "GeographicCRS",
"name": f"EPSG:{epsg_int}",
"id": {"authority": "EPSG", "code": epsg_int},
},
"bbox": bbox_list,
}
},
}
geo_bytes = json.dumps(geo_meta, separators=(",", ":")).encode("utf-8")
existing_meta: dict = dict(table.schema.metadata or {})
existing_meta[b"geo"] = geo_bytes
return table.replace_schema_metadata(existing_meta)
The encoding field must be WKB. GeoParquet v1.0.0 mandates WKB for performance and cross-engine interoperability. If you are migrating QGIS projects that rely on proprietary styling or layer metadata, see preserving QGIS metadata in FlatGeobuf — FlatGeobuf handles QGIS-specific tags more gracefully before a subsequent conversion to Parquet.
Step 5 — Write, Validate, and Integrate
Writing the file is straightforward, but validation is mandatory before promoting to production. The write parameters below are optimised for cloud analytical workloads; adjust row_group_size if you are tuning for row group sizing strategies:
from pathlib import Path
import pyarrow as pa # >=14.0.0
import pyarrow.parquet as pq
def write_geoparquet(table: pa.Table, output_path: Path) -> None:
"""Write a validated GeoParquet file with ZSTD compression.
Uses row_group_size=100_000 which balances cloud query scan efficiency
against per-file footer overhead. Adjust downward (50_000) for tile-level
serving and upward (250_000) for large analytical batch exports.
Raises RuntimeError if the 'geo' footer key is absent after writing —
this should never happen if build_geoparquet_metadata() ran successfully,
but acts as a production safety net.
"""
pq.write_table(
table,
str(output_path),
compression="zstd",
compression_level=3,
use_dictionary=True,
write_statistics=True,
row_group_size=100_000,
)
# Validate: re-read the schema and confirm the geo key is present
written_schema = pq.read_schema(str(output_path))
metadata = written_schema.metadata or {}
if b"geo" not in metadata:
raise RuntimeError(
f"GeoParquet 'geo' footer key is absent in {output_path}. "
"Check that build_geoparquet_metadata() ran before this function."
)
For enterprise-scale operations, wrap these functions in an orchestration framework like Apache Airflow or Prefect. Implement retry logic, dead-letter queues for malformed geometries, and checksum verification. When scaling horizontally, partition datasets by spatial index or temporal buckets to avoid skew. Detailed orchestration patterns are covered in building batch conversion pipelines with Python.
Production-Ready Combined Pipeline
The following module wires all four functions into a single, callable entry point with structured logging and checksum output:
#!/usr/bin/env python3
"""geoparquet_convert.py — Production metadata-preserving GeoParquet converter.
Dependencies (pin in requirements.txt):
pyarrow>=14.0.0
geopandas>=1.0.0
pyogrio>=0.7.0
shapely>=2.0.0
pandas>=2.0.0
"""
import hashlib
import json
import logging
import re
from pathlib import Path
from typing import Any
import geopandas as gpd
import pandas as pd
import pyarrow as pa
import pyarrow.parquet as pq
import pyogrio
log = logging.getLogger(__name__)
def extract_source_metadata(source_path: Path) -> dict[str, Any]:
meta = pyogrio.read_info(str(source_path))
crs_raw: str = meta.get("crs") or ""
crs_epsg = "unknown"
if "EPSG:" in crs_raw:
crs_epsg = crs_raw.split("EPSG:")[-1].split("]")[0].strip()
elif crs_raw.lstrip("-").isdigit():
crs_epsg = crs_raw.strip()
return {
"crs": crs_epsg,
"bbox": meta.get("total_bounds"),
"geometry_type": meta.get("geometry_type", "Unknown"),
}
def map_and_coerce_schema(gdf: gpd.GeoDataFrame) -> pa.Table:
gdf = gdf.copy()
geom_col = gdf.geometry.name
rename_map = {
c: re.sub(r"[^a-z0-9_]", "_", c.lower()).strip("_")
for c in gdf.columns
if c != geom_col
}
gdf = gdf.rename(columns={k: v for k, v in rename_map.items() if k != v})
for col in gdf.columns:
if col == geom_col:
continue
if pd.api.types.is_datetime64_any_dtype(gdf[col]):
gdf[col] = gdf[col].dt.tz_localize(None).astype("datetime64[ms]")
gdf = gdf.replace("", pd.NA)
return pa.Table.from_pandas(gdf, preserve_index=False)
def build_geoparquet_metadata(
table: pa.Table,
source_meta: dict[str, Any],
primary_column: str = "geometry",
) -> pa.Table:
try:
epsg_int = int(source_meta.get("crs", "4326"))
except (ValueError, TypeError):
log.warning("Unknown CRS — defaulting to EPSG:4326. Verify before production use.")
epsg_int = 4326
bbox = source_meta.get("bbox")
geo_meta: dict[str, Any] = {
"version": "1.0.0",
"primary_column": primary_column,
"columns": {
primary_column: {
"encoding": "WKB",
"geometry_types": [source_meta.get("geometry_type", "Unknown")],
"crs": {
"$schema": "https://proj.org/schemas/v0.7/projjson.schema.json",
"type": "GeographicCRS",
"name": f"EPSG:{epsg_int}",
"id": {"authority": "EPSG", "code": epsg_int},
},
"bbox": list(bbox) if bbox is not None else None,
}
},
}
geo_bytes = json.dumps(geo_meta, separators=(",", ":")).encode("utf-8")
meta = dict(table.schema.metadata or {})
meta[b"geo"] = geo_bytes
return table.replace_schema_metadata(meta)
def convert(source_path: Path, output_path: Path) -> dict[str, Any]:
"""Convert source vector to metadata-preserving GeoParquet.
Returns a result dict with output path, row count, and SHA-256 checksum.
"""
log.info("Reading %s", source_path)
source_meta = extract_source_metadata(source_path)
if source_meta["crs"] == "unknown":
log.warning("No CRS detected in %s — quarantine recommended.", source_path)
gdf: gpd.GeoDataFrame = gpd.read_file(str(source_path), engine="pyogrio")
table = map_and_coerce_schema(gdf)
table = build_geoparquet_metadata(table, source_meta)
output_path.parent.mkdir(parents=True, exist_ok=True)
pq.write_table(
table,
str(output_path),
compression="zstd",
compression_level=3,
use_dictionary=True,
write_statistics=True,
row_group_size=100_000,
)
written_schema = pq.read_schema(str(output_path))
if b"geo" not in (written_schema.metadata or {}):
raise RuntimeError(f"'geo' footer key missing in output {output_path}")
sha256 = hashlib.sha256(output_path.read_bytes()).hexdigest()
log.info("Written %s — %d rows, SHA-256: %s", output_path, len(gdf), sha256)
return {"output": str(output_path), "rows": len(gdf), "sha256": sha256}
if __name__ == "__main__":
import sys
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
result = convert(Path(sys.argv[1]), Path(sys.argv[2]))
print(json.dumps(result, indent=2))
Benchmark Reference Matrix
The table below shows observed compression ratios and write latencies for a 1 M-feature municipal boundaries dataset (mixed MultiPolygon, EPSG:4326) on an M2 MacBook Pro. Numbers will vary with geometry complexity and column cardinality.
| Compression | Level | File Size | Write Time | Read (DuckDB spatial query) | Primary Use Case |
|---|---|---|---|---|---|
| None (plain) | — | 2 340 MB | 4.1 s | 1.8 s | Debugging, schema inspection |
| Snappy | — | 1 180 MB | 4.5 s | 1.1 s | Spark workloads, Java ecosystem |
| ZSTD | 1 | 820 MB | 4.7 s | 0.9 s | Streaming ingestion, write-heavy |
| ZSTD | 3 | 740 MB | 5.1 s | 0.85 s | General analytical lake (recommended) |
| ZSTD | 9 | 695 MB | 11.8 s | 0.82 s | Archive, cold storage, infrequent reads |
ZSTD level 3 is the recommended default for geospatial vector data. See ZSTD compression levels for geospatial data for a full benchmark methodology and raster comparisons.
Failure Modes and Gotchas
Even with careful implementation, certain edge cases consistently break conversions:
Multi-CRS datasets. GeoParquet v1.0.0 supports only one CRS per geometry column. If your source contains mixed projections, normalise to a single target CRS (typically EPSG:4326 or EPSG:3857) before conversion. A silent reproject to the wrong CRS is worse than a loud exception — downstream spatial joins will silently produce wrong results.
Large geometry blobs. WKB encoding can produce large binary payloads for complex polygons with thousands of vertices. Enable ZSTD compression and consider topological simplification (shapely.simplify() with preserve_topology=True) before conversion when serving tiles or map layers. Analytic workloads that only access attribute columns are unaffected, thanks to columnar skipping.
Missing CRS definitions. Shapefiles without .prj files default to unknown CRS. Implement a fallback routing mechanism that quarantines these files for manual review rather than injecting a default projection silently. See fallback routing for failed migration jobs for a production-ready quarantine queue pattern.
Metadata bloat. Parquet footers are loaded into memory during file open. Keep the geo JSON lean — move extensive provenance logs to a companion metadata catalog such as OpenMetadata or AWS Glue. A footer exceeding ~50 KB adds measurable overhead on frequent cold opens in serverless query environments.
Mixed geometry collections. The geometry_types array in the geo footer should accurately list all geometry types present. Writing ["MultiPolygon"] when the file also contains Polygon features causes GDAL to report incorrect layer metadata and may confuse tiling pipelines. Either normalise to a single type or list all types explicitly: ["Polygon", "MultiPolygon"].
Arrow schema metadata surviving round-trips. pa.Table.from_pandas() injects b"pandas" into the schema metadata. This is harmless but adds a few kilobytes. In storage-sensitive environments, strip it: table = table.replace_schema_metadata({k: v for k, v in (table.schema.metadata or {}).items() if k != b"pandas"}).
Frequently Asked Questions
Does GeoParquet support multiple CRS per file?
No. GeoParquet v1.0.0 permits only one CRS per geometry column. If your source dataset mixes projections, you must reproject to a single target CRS — typically EPSG:4326 or a local projected CRS — before writing the Parquet file.
What happens if I omit the geo key from the Parquet footer?
Spatial engines such as DuckDB’s spatial extension and GDAL will treat the file as a plain Parquet table with no geometry awareness. Geometry columns become opaque binary blobs, spatial predicates fail, and bounding-box queries scan the entire file rather than using the statistics embedded in row-group footers.
How do I preserve custom provenance fields that have no GeoParquet equivalent?
Store them as additional key-value pairs in the Parquet file metadata alongside the geo key. Use a namespaced prefix — for example b"org.yourcompany.lineage" — to avoid collisions with future GeoParquet specification keys. Pass them as extra entries when calling table.replace_schema_metadata().
Which geometry encoding should I use — WKB or WKT?
Use WKB for all production workloads. It is more compact, avoids floating-point serialisation rounding, and is the encoding required by GeoParquet v1.0.0 for interoperability with DuckDB, Sedona, and GDAL. WKT is acceptable only for small debug files or human-readable exports.
Related
- Building Batch Conversion Pipelines with Python — orchestration patterns, parallelism, and idempotency for large-scale format migrations
- Schema Mapping for Legacy to Modern Formats — type coercion matrices, column naming conventions, and null-handling strategies
- Fallback Routing for Failed Migration Jobs — quarantine queues, retry logic, and dead-letter strategies for corrupt or CRS-missing files
- Preserving QGIS Metadata in FlatGeobuf — handling QGIS-specific layer styles and custom tags before Parquet conversion
- ZSTD Compression Levels for Geospatial Data — benchmark data and level recommendations for vector and raster workloads
- GeoParquet vs FlatGeobuf Performance — read-path, streaming, and analytical trade-offs to pick the right conversion target
← Back to Data Conversion & Migration Pipelines