GeoParquet Encoding Standards for Lakehouse Tables

GeoParquet is the convention that turns an ordinary Parquet file into a self-describing spatial dataset by attaching a "geo" key to the file’s key-value metadata. That single metadata block — not a sidecar .prj, not an out-of-band catalog entry — is what lets DuckDB, GDAL, GeoPandas, Sedona, and Trino agree on which column holds geometry, how it is serialized, and what coordinate reference system it lives in. This guide covers the GeoParquet 1.1 specification as it applies to open-table lakehouse storage: the structure of the geo metadata, WKB column encoding, the primary_column/columns/bbox/covering fields, PROJJSON CRS storage, the native Parquet GEOMETRY logical type, and how engines actually read all of it at scan time. It is written for data engineers and platform architects who are standardizing how geometry lands in an Iceberg or Delta table and want the on-disk contract to be unambiguous across every reader.

When to use file-level GeoParquet metadata

The core decision is whether to lean on GeoParquet’s file-level geo metadata or to treat geometry as a plain WKB BINARY column with the spatial contract enforced entirely in your pipeline. The metadata approach buys interoperability and self-description; the raw-column approach buys engine-agnostic simplicity at the cost of every reader needing out-of-band knowledge of the CRS and encoding. The table below frames the choice; the dedicated GeoParquet vs WKB column storage trade-offs guide runs the full comparison with runnable code for both.

Requirement GeoParquet file metadata Raw WKB BINARY column
Cross-tool interoperability (GDAL, DuckDB, QGIS) Native — CRS + encoding travel with the file Manual — every reader needs external CRS knowledge
CRS captured in the file Yes, as PROJJSON in geo.columns.<name>.crs No — stored elsewhere or lost
File-skipping bbox statistics Yes, via the covering bbox struct Only if you hand-roll bbox columns
Works with engines that ignore geo Degrades to a binary column Always
Round-trips through Iceberg/Delta metadata Partial — table formats may drop file KV metadata Full — it is just a column

Reach for file-level metadata when the same physical files are read by heterogeneous tools or handed to external consumers. Reach for raw WKB columns, plus explicit bounding-box columns, when the files live behind an Iceberg spatial table whose catalog is the single source of truth and whose manifest statistics already drive predicate pushdown.

Architecture: what lives inside a GeoParquet file

GeoParquet 1.1 file layout A Parquet file with two row groups, each holding a WKB geometry column and a bbox covering struct, and a file footer whose key-value metadata carries the geo block with version, primary_column, columns, CRS as PROJJSON, and covering. Engines read the footer first. Parquet data (.parquet) Row group 0 geometry WKB BINARY bbox struct xmin..ymax Row group 1 geometry WKB BINARY bbox struct xmin..ymax Footer key-value: "geo" version: "1.1.0" primary_column: "geometry" columns.geometry.encoding: WKB columns.geometry.crs: PROJJSON columns.geometry.covering.bbox columns.geometry.geometry_types[] read first

A GeoParquet file is a normal Parquet file plus a JSON document stored under the key geo in the file footer’s key-value metadata. Every compliant reader opens the footer, parses that JSON, and only then knows how to interpret the geometry column. The document has a version, a primary_column naming the default geometry column, and a columns map. Each entry in columns describes one geometry column: its encoding (WKB in 1.0, or point/linestring/other native GeoArrow encodings added in 1.1), its geometry_types array, its crs as a PROJJSON object, and — the headline 1.1 feature — an optional covering field that points at a per-row bounding-box struct column engines can use for file and row-group skipping.

Prerequisites and environment setup

The reference stack for authoring and inspecting GeoParquet is GeoPandas on top of pyarrow and Shapely. GeoPandas 1.0 writes GeoParquet 1.1 including the covering bbox by default when you pass write_covering_bbox=True; pyarrow gives you raw access to the footer metadata for verification.

bash
python -m pip install \
  "geopandas>=1.0.1" \
  "pyarrow>=17.0.0" \
  "shapely>=2.0.4" \
  "pyproj>=3.6.1"
python
import geopandas
import pyarrow
import pyproj

# Confirm the toolchain versions that support GeoParquet 1.1 + covering bbox.
print("geopandas", geopandas.__version__)
print("pyarrow", pyarrow.__version__)
print("pyproj", pyproj.__version__)
assert tuple(int(p) for p in geopandas.__version__.split(".")[:2]) >= (1, 0)

Step-by-step implementation

Step 1 — Build a GeoDataFrame with an explicit CRS

Never let the CRS default. Set it explicitly with the numeric EPSG code so the written PROJJSON is deterministic. Silent CRS assumptions are the root cause of most downstream join failures; the CRS management pipelines section covers enforcing this at ingest, and detecting CRS drift in ingestion pipelines covers catching it after the fact.

python
import geopandas
from shapely.geometry import Point, Polygon

gdf = geopandas.GeoDataFrame(
    {
        "feature_id": ["sensor-001", "sensor-002", "zone-a"],
        "kind": ["point", "point", "polygon"],
    },
    geometry=[
        Point(-122.4194, 37.7749),
        Point(-122.2712, 37.8044),
        Polygon([(-122.52, 37.70), (-122.35, 37.70),
                 (-122.35, 37.83), (-122.52, 37.83)]),
    ],
    crs=4326,  # numeric EPSG literal -> written as PROJJSON in the geo metadata
)
print(gdf.crs.to_epsg())  # 4326

Step 2 — Write GeoParquet 1.1 with a bbox covering

The write_covering_bbox=True flag materializes a bbox struct column (xmin, ymin, xmax, ymax) per row and records a covering.bbox entry in the geo metadata pointing at it. Combined with row-group statistics, this is what lets a reader skip row groups whose bounds miss the query window.

python
gdf.to_parquet(
    "sensors.parquet",
    engine="pyarrow",
    schema_version="1.1.0",       # emit GeoParquet 1.1 metadata
    write_covering_bbox=True,     # add per-row bbox struct + covering entry
    geometry_encoding="WKB",      # WKB is the maximally interoperable encoding
    compression="zstd",
)

Read the footer key-value metadata straight from pyarrow to confirm exactly what was written. This is the same block a CI gate should assert on, as detailed in validating GeoParquet metadata in CI.

python
import json
import pyarrow.parquet as pq

pf = pq.ParquetFile("sensors.parquet")
kv = pf.metadata.metadata  # dict of bytes -> bytes
geo = json.loads(kv[b"geo"].decode("utf-8"))

print("version:", geo["version"])
print("primary_column:", geo["primary_column"])
col = geo["columns"][geo["primary_column"]]
print("encoding:", col["encoding"])
print("geometry_types:", col["geometry_types"])
print("covering:", col.get("covering"))
print("crs id:", col["crs"]["id"])  # {'authority': 'EPSG', 'code': 4326}

Step 4 — Understand the native Parquet GEOMETRY logical type

GeoParquet’s geo block is a convention layered on top of a BINARY column. Separately, the Parquet format itself is gaining a native GEOMETRY (and GEOGRAPHY) logical type that annotates the column in the Parquet schema, carrying the CRS and edge interpolation as part of the type rather than as file metadata. The two are complementary: a writer can emit a column whose Parquet logical type is GEOMETRY and still publish a geo block for readers that key off it. When your engine and file writer both support the native logical type, prefer it — statistics become first-class and CRS travels in the schema. Until the ecosystem catches up, the geo metadata plus WKB remains the portable baseline, which is why GeoPandas still defaults to WKB.

Verification and testing

Verify three things: the geometry survived a round-trip, the covering bbox is queryable, and the CRS is intact. DuckDB (spatial extension ≥ 1.0) reads GeoParquet natively and exposes the bbox for pushdown.

python
import geopandas

# Round-trip: geometry and CRS preserved.
back = geopandas.read_parquet("sensors.parquet")
assert back.crs.to_epsg() == 4326
assert back.geometry.geom_type.tolist() == ["Point", "Point", "Polygon"]
print("total_bounds:", back.total_bounds)  # [xmin ymin xmax ymax]
sql
-- DuckDB (spatial >= 1.0): confirm the covering bbox drives file skipping.
INSTALL spatial; LOAD spatial;
SELECT count(*)
FROM read_parquet('sensors.parquet')
WHERE bbox.xmin < -122.30 AND bbox.xmax > -122.55
  AND bbox.ymin <  37.85 AND bbox.ymax >  37.68;

A correct write yields a bbox struct column visible in the Parquet schema, covering.bbox populated in the geo metadata, and a crs.id of {"authority": "EPSG", "code": 4326}. If any is missing, treat the file as non-conforming.

Performance and tuning

The covering bbox only helps if geometries are spatially clustered within row groups; a randomly ordered file has row-group bounds that all overlap the query window, defeating skipping. Sort by a space-filling key before writing so nearby features share row groups — the same clustering discipline that predicate pushdown optimization depends on.

  • Row-group size: keep the Parquet row group at 64–128 MB (row_group_size measured in rows for your average geometry width). Smaller groups give finer skipping granularity but inflate footer metadata; larger groups coarsen the bbox and read amplification climbs.
  • Compression: zstd at level 3 typically beats snappy by 20–35% on WKB polygon columns with negligible decode cost. WKB compresses poorly compared to coordinate-delta encodings, so this matters.
  • Encoding choice: WKB maximizes interoperability; GeoParquet 1.1 native GeoArrow encodings (point, etc.) can be 2–4x smaller and faster to decode for homogeneous point datasets but are read by fewer engines.
  • Covering overhead: the bbox struct adds four DOUBLE columns — roughly 32 bytes/row before compression. On point-heavy tables that can be 10–15% of file size, so drop it only if no reader does spatial range filtering.

Common errors and fixes

Symptom Root cause Fix
Reader sees geometry as opaque BINARY, no spatial ops geo metadata absent or primary_column unset Rewrite with GeoPandas/to_parquet(schema_version="1.1.0"); assert b"geo" in footer KV
crs.id missing or CRS reads as null GeoDataFrame written without a CRS set Set crs=4326 before write; never rely on the default
No row-group skipping despite a bbox filter write_covering_bbox omitted or data unsorted Write with write_covering_bbox=True and sort by a geohash/Hilbert key first
Trino/Sedona ignores the covering bbox Engine reads 1.0 metadata only Keep explicit DOUBLE bbox columns as a fallback; verify engine GeoParquet 1.1 support
Table-format read drops the geo block Iceberg/Delta may not surface file KV metadata to the engine Store CRS/encoding in table properties too; treat catalog as source of truth

Standardizing GeoParquet encoding is the file-level half of a spatial lakehouse contract; the catalog-level half lives in Iceberg spatial type support and the broader Spatial Lakehouse Fundamentals & Architecture section. Anchor every implementation to the canonical GeoParquet specification and the OGC Simple Features coordinate model so the metadata you write stays portable across the entire tool ecosystem.