Understanding Parquet Columnar Storage for GIS

Modern geospatial analytics demand storage formats that scale with cloud-native architectures, support predicate pushdown, and minimise I/O overhead. The core challenge is that row-oriented legacy formats force engines to deserialise entire records — coordinates, topology rings, and dozens of attribute columns — even when a query touches only two or three fields. Parquet solves this by grouping values of the same column together on disk, so the engine can skip geometry bytes entirely when a query only needs region and area. This architectural shift directly impacts query latency, cloud egress costs, and the feasibility of serverless geospatial processing.

This page covers the Parquet internals that matter for GIS, walks through a production-grade conversion and query workflow, and documents the failure modes that catch engineers off guard. For the broader landscape of format choices — including when row-based formats are the right call — see Geospatial Storage Fundamentals & Format Comparison.


Parquet file anatomy for GIS workloads A Parquet file is divided into row groups. Each row group holds column chunks for geometry, attributes, and partition keys. Column-chunk headers carry min/max statistics that allow a query engine to skip entire row groups without decompression. Parquet File Row Group 1 (rows 0 – 99 999) geometry (WKB) area_sqm (DOUBLE) land_use (DICT) Column stats: min/max bbox · null count · distinct values ↑ query engine reads this first → skips RG if no match Page 1 … Page N Each column chunk is independently compressed (ZSTD / Snappy) Row Group 2 (rows 100 000 – 199 999) geometry (WKB) area_sqm (DOUBLE) land_use (DICT) Column stats: min/max bbox · null count · distinct values ↑ bbox outside query envelope → entire RG skipped ✓ Page 1 … Page N File Footer — schema · row-group offsets · column statistics (read first; last 8–32 KB of file) query engine entry point
Parquet file anatomy: the footer is fetched first; column-chunk statistics then let the engine skip entire row groups before any decompression occurs.

Prerequisites

Before implementing columnar geospatial pipelines, confirm your environment meets the following baseline requirements:

  • Python 3.9+ with pyarrow>=12.0, geopandas>=0.14, and duckdb>=1.0 installed
  • Cloud storage access — S3, GCS, or Azure Blob configured with appropriate IAM credentials or local fallback paths
  • Familiarity with coordinate reference systems (CRS) and WKB/WKT geometry encoding
  • Basic SQL knowledge for writing spatial predicates against DuckDB
  • Understanding of spatial partitioning strategies — Hive-style administrative boundaries or H3/S2 grid partitioning

Validate your stack before proceeding:

python
# pyarrow>=12.0, geopandas>=0.14, duckdb>=1.0
import sys
import pyarrow as pa
import geopandas as gpd
import duckdb

print(f"Python:    {sys.version.split()[0]}")
print(f"PyArrow:   {pa.__version__}")
print(f"GeoPandas: {gpd.__version__}")
print(f"DuckDB:    {duckdb.__version__}")

Architectural Foundations

Why columnar layout benefits spatial analytics

Parquet organises data hierarchically: a file contains one or more row groups, each row group contains column chunks, and each chunk is subdivided into pages. Two properties flow directly from this layout:

Column pruning. When querying SELECT region, area FROM parcels, the geometry column is never deserialised. For typical parcel datasets where WKB geometry accounts for 60–80% of uncompressed row size, skipping that column alone cuts I/O by more than half — the column pruning benefits in geospatial Parquet page quantifies this savings by workload type.

Predicate pushdown. Statistics stored in each column-chunk header — minimum value, maximum value, null count, and (under GeoParquet) a bounding-box extent — allow query engines to skip entire row groups before decompression. A spatial filter like ST_Intersects(geometry, query_envelope) is evaluated against the stored bbox statistics first; row groups whose bbox has no overlap with the query envelope are never touched.

Geometry columns encoded as WKB binary also compress significantly better than text equivalents. ZSTD exploits the spatial correlation of coordinate sequences (adjacent records in a sorted dataset have similar coordinate values), yielding 3–6× size reductions over raw WKB. For a detailed treatment of compression level selection, see ZSTD compression levels for geospatial data.

GeoParquet: standardised metadata layer

Plain Parquet has no concept of coordinate reference systems or geometry column semantics. The GeoParquet specification adds a geo key to Parquet’s file-level metadata that records:

  • Which column(s) hold geometry (as WKB binary)
  • The encoding type (WKB is the primary encoding)
  • The CRS as a PROJJSON object or EPSG code
  • Bounding-box statistics per geometry column

This metadata enables interoperability across DuckDB, Apache Arrow, Spark, and cloud data warehouses without custom parsing logic. GeoPandas 0.14+ writes GeoParquet-compliant output automatically when you call to_parquet().

By contrast, row-based formats like CSV or Shapefiles force full-record sequential scans, carry no column statistics, and cannot selectively decompress a single attribute column. Text-based interchange formats fare worse still: the serialization overhead of GeoJSON inflates coordinate payloads with repeated delimiters and ASCII float encoding that no per-column compressor can claw back.

Predicate pushdown execution flow for spatial queries on GeoParquet A spatial query against a GeoParquet file follows four stages: (1) fetch footer via HTTP range request, (2) evaluate bbox statistics for each row group, (3) skip row groups whose bbox does not intersect the query envelope, (4) decompress and return only matching column chunks. 1. Fetch Footer HTTP range request (last 8–32 KB) 2. Evaluate Stats bbox min/max per row group vs query 3. Skip or Read? bbox overlaps → read column chunks no overlap → skip RG 4. Decompress target columns only (geometry skipped if not selected) Row group skipped 0 bytes decompressed Spatial predicate pushdown — query execution path Column statistics in each row-group header drive the skip/read decision before any decompression
Predicate pushdown in action: the query engine reads the file footer once, then evaluates per-row-group bounding-box statistics to decide whether to decompress each group — typically skipping 60–95% of row groups for tightly scoped spatial queries.

Step-by-Step Workflow

Step 1: Profile and load source data

Before writing Parquet, understand the shape of your data: row count, geometry type distribution, and attribute cardinality. This informs row-group sizing and compression strategy choices.

python
# pyarrow>=12.0, geopandas>=0.14
from __future__ import annotations
import geopandas as gpd

def profile_geodataframe(path: str) -> dict:
    """Load a spatial file and return key profile metrics."""
    gdf = gpd.read_file(path)

    profile = {
        "rows": len(gdf),
        "crs": str(gdf.crs),
        "geom_types": gdf.geometry.geom_type.value_counts().to_dict(),
        "null_geom": gdf.geometry.isna().sum(),
        "invalid_geom": (~gdf.geometry.is_valid).sum(),
        "columns": list(gdf.columns),
        "high_cardinality_cols": [
            col for col in gdf.select_dtypes(include="object").columns
            if gdf[col].nunique() > 1000
        ],
    }
    return profile

profile = profile_geodataframe("data/parcels.shp")
print(profile)

Step 2: Normalise geometry and CRS

Consistent CRS and valid geometry are non-negotiable before serialisation. A CRS mismatch discovered after writing millions of rows forces a costly full rewrite.

python
# pyarrow>=12.0, geopandas>=0.14
from __future__ import annotations
import geopandas as gpd

def normalise_geodataframe(gdf: gpd.GeoDataFrame, target_epsg: int = 4326) -> gpd.GeoDataFrame:
    """Reproject, repair invalid geometries, and drop nulls."""
    if gdf.crs is None:
        raise ValueError("Source data has no CRS — cannot safely reproject")

    gdf = gdf.to_crs(epsg=target_epsg)

    # Drop null geometries before validity check to avoid TypeError
    gdf = gdf[gdf.geometry.notna()].copy()

    # Attempt buffer(0) repair on invalid geometries
    invalid_mask = ~gdf.geometry.is_valid
    if invalid_mask.any():
        gdf.loc[invalid_mask, "geometry"] = gdf.loc[invalid_mask, "geometry"].buffer(0)
        # Drop any that remain invalid after repair
        gdf = gdf[gdf.geometry.is_valid].copy()

    return gdf

Step 3: Serialise to GeoParquet

GeoPandas provides the simplest path to GeoParquet-compliant output. The row_group_size parameter controls both memory pressure during writes and the granularity of predicate pushdown during reads. For background on tuning this parameter, see row-group sizing strategies for Parquet.

python
# pyarrow>=12.0, geopandas>=0.14
def write_geoparquet(
    gdf: gpd.GeoDataFrame,
    output_path: str,
    row_group_size: int = 100_000,
    compression: str = "zstd",
) -> None:
    """Write a GeoParquet file with statistics enabled."""
    gdf.to_parquet(
        output_path,
        index=False,
        compression=compression,
        row_group_size=row_group_size,
    )
    print(f"Written: {output_path}  (row_group_size={row_group_size}, compression={compression})")

For high-throughput pipelines where you need explicit control over encoding and statistics, use PyArrow directly:

python
# pyarrow>=12.0
import pyarrow as pa
import pyarrow.parquet as pq

def write_geoparquet_pyarrow(
    gdf: gpd.GeoDataFrame,
    output_path: str,
    row_group_size: int = 200_000,
    zstd_level: int = 3,
) -> None:
    """Write GeoParquet via PyArrow with explicit encoding and statistics."""
    table = pa.Table.from_pandas(gdf, preserve_index=False)

    pq.write_table(
        table,
        output_path,
        compression="zstd",
        compression_level=zstd_level,
        row_group_size=row_group_size,
        use_dictionary=True,       # enables dictionary encoding for low-cardinality columns
        write_statistics=True,     # mandatory for predicate pushdown
    )

Note: PyArrow’s from_pandas does not inject GeoParquet metadata. If downstream consumers depend on the geo metadata key (for CRS, geometry column name, or bbox statistics), use gdf.to_parquet() via GeoPandas, or manually write the geo metadata using geopandas.io.arrow.geopandas_to_arrow.

When evaluating whether to use GeoParquet or an alternative format for your access patterns, comparing GeoParquet vs FlatGeobuf performance covers the read/write trade-offs in detail.

Step 4: Apply spatial partitioning

Hive-style partitioning by administrative boundary or spatial grid cell dramatically reduces scan volume for region-scoped queries. H3 cells at resolution 7 (average area ~5 km²) work well for parcel-scale urban datasets.

python
# pyarrow>=12.0, geopandas>=0.14, h3>=4.0
from __future__ import annotations
import geopandas as gpd

def add_h3_partition_key(gdf: gpd.GeoDataFrame, resolution: int = 7) -> gpd.GeoDataFrame:
    """Add an H3 cell key for Hive-style spatial partitioning.

    Requires h3>=4.0; for h3<4.0 use h3.geo_to_h3(lat, lng, resolution).
    """
    try:
        import h3
    except ImportError as exc:
        raise ImportError("pip install h3>=4.0") from exc

    gdf = gdf.copy()

    def _cell(geom):
        if geom is None or geom.is_empty:
            return None
        c = geom.centroid
        return h3.latlng_to_cell(c.y, c.x, resolution)

    gdf[f"h3_r{resolution}"] = gdf.geometry.apply(_cell)
    return gdf

def write_partitioned_geoparquet(
    gdf: gpd.GeoDataFrame,
    output_dir: str,
    partition_col: str = "h3_r7",
) -> None:
    """Write Hive-partitioned GeoParquet output."""
    import pyarrow as pa
    import pyarrow.parquet as pq

    table = pa.Table.from_pandas(gdf, preserve_index=False)
    pq.write_to_dataset(
        table,
        root_path=output_dir,
        partition_cols=[partition_col],
        compression="zstd",
        use_dictionary=True,
        write_statistics=True,
    )
    print(f"Partitioned dataset written to {output_dir}/")

Before writing partitioned output, sort rows by spatial proximity. Sorting by centroid coordinates groups spatially adjacent records within the same row group, improving both compression ratios (correlated coordinate values compress better together) and bounding-box statistics accuracy (tighter per-row-group bboxes enable more aggressive skipping).

python
# geopandas>=0.14
def sort_by_spatial_proximity(gdf: gpd.GeoDataFrame) -> gpd.GeoDataFrame:
    """Approximate Z-order sort using interleaved centroid coordinates.

    For production, replace with a proper Hilbert-curve library (e.g. hilbertcurve).
    """
    gdf = gdf.copy()
    gdf["_cx"] = gdf.geometry.centroid.x
    gdf["_cy"] = gdf.geometry.centroid.y
    gdf = gdf.sort_values(["_cx", "_cy"]).drop(columns=["_cx", "_cy"])
    return gdf

Step 5: Query with DuckDB

DuckDB’s native Parquet reader and spatial extension enable serverless analytics without loading data into process memory. The engine fetches the file footer via HTTP range requests, evaluates column-chunk statistics, and issues targeted reads for only the relevant row groups and columns.

sql
-- Install and load extensions once per session
INSTALL spatial;
LOAD spatial;

-- Direct analytical query over GeoParquet on disk or S3
SELECT
    region,
    COUNT(*)                        AS parcel_count,
    ROUND(SUM(area_sqm) / 1e6, 2)  AS total_area_km2,
    ROUND(AVG(area_sqm), 1)         AS avg_area_sqm
FROM read_parquet('s3://your-bucket/parcels/**/*.parquet')
WHERE ST_Intersects(
    ST_GeomFromWKB(geometry),
    ST_MakeEnvelope(-122.5, 37.7, -122.3, 37.9)
)
GROUP BY region
ORDER BY total_area_km2 DESC;

For S3 access, also load the httpfs extension and configure credentials:

sql
INSTALL httpfs;
LOAD httpfs;
SET s3_region = 'us-west-2';
SET s3_access_key_id = '...';
SET s3_secret_access_key = '...';

Step 6: Validate the output

After converting, confirm that GeoParquet metadata is present, that row-group statistics are populated, and that query results match the source data.

python
# pyarrow>=12.0
import json
import pyarrow.parquet as pq

def validate_geoparquet(path: str) -> None:
    """Print key metadata and confirm GeoParquet compliance."""
    pf = pq.ParquetFile(path)
    meta = pf.metadata

    print(f"Row groups:  {meta.num_row_groups}")
    print(f"Total rows:  {meta.num_rows}")
    print(f"Columns:     {meta.num_columns}")

    # Check GeoParquet geo metadata key
    kv = {kv.key: kv.value for kv in meta.metadata.items()}
    if b"geo" in kv:
        geo = json.loads(kv[b"geo"])
        print(f"CRS:         {geo.get('columns', {})}")
    else:
        print("WARNING: 'geo' metadata key missing — not GeoParquet compliant")

    # Verify statistics exist on the first geometry column
    rg0 = meta.row_group(0)
    for i in range(rg0.num_columns):
        col = rg0.column(i)
        if col.statistics:
            print(f"  {col.path_in_schema}: has_min_max={col.statistics.has_min_max}")
        else:
            print(f"  {col.path_in_schema}: NO STATISTICS — predicate pushdown disabled")

Production-Ready Implementation

The following function assembles the above steps into a single, error-handled pipeline suitable for batch or scheduled runs:

python
# pyarrow>=12.0, geopandas>=0.14, h3>=4.0
from __future__ import annotations
import logging
import time
from pathlib import Path

import geopandas as gpd
import pyarrow.parquet as pq

logger = logging.getLogger(__name__)


def convert_to_geoparquet(
    source_path: str,
    output_path: str,
    target_epsg: int = 4326,
    row_group_size: int = 100_000,
    compression: str = "zstd",
    zstd_level: int = 3,
    add_h3_key: bool = True,
    h3_resolution: int = 7,
) -> dict:
    """
    Full pipeline: load → normalise → (optionally) add H3 key → write GeoParquet.

    Returns a summary dict with row counts, file size, and elapsed time.
    """
    t0 = time.perf_counter()

    logger.info("Loading %s", source_path)
    gdf = gpd.read_file(source_path)
    rows_in = len(gdf)

    # --- Normalise ---
    if gdf.crs is None:
        raise ValueError(f"No CRS on {source_path}; set it explicitly before conversion")

    gdf = gdf.to_crs(epsg=target_epsg)
    gdf = gdf[gdf.geometry.notna()].copy()
    invalid = ~gdf.geometry.is_valid
    if invalid.any():
        gdf.loc[invalid, "geometry"] = gdf.loc[invalid, "geometry"].buffer(0)
        gdf = gdf[gdf.geometry.is_valid].copy()

    rows_out = len(gdf)
    dropped = rows_in - rows_out
    if dropped:
        logger.warning("Dropped %d rows with null/invalid geometry", dropped)

    # --- Spatial sort for better compression and bbox stats ---
    gdf["_cx"] = gdf.geometry.centroid.x
    gdf["_cy"] = gdf.geometry.centroid.y
    gdf = gdf.sort_values(["_cx", "_cy"]).drop(columns=["_cx", "_cy"])

    # --- Optional H3 partition key ---
    if add_h3_key:
        try:
            import h3
            col = f"h3_r{h3_resolution}"
            gdf[col] = gdf.geometry.apply(
                lambda g: h3.latlng_to_cell(g.centroid.y, g.centroid.x, h3_resolution)
                if g and not g.is_empty else None
            )
        except ImportError:
            logger.warning("h3 not installed; skipping H3 partition key")

    # --- Write ---
    Path(output_path).parent.mkdir(parents=True, exist_ok=True)
    gdf.to_parquet(
        output_path,
        index=False,
        compression=compression,
        row_group_size=row_group_size,
    )

    elapsed = time.perf_counter() - t0
    file_size_mb = Path(output_path).stat().st_size / 1_048_576

    return {
        "rows_in": rows_in,
        "rows_out": rows_out,
        "rows_dropped": dropped,
        "file_size_mb": round(file_size_mb, 2),
        "elapsed_s": round(elapsed, 2),
        "compression": compression,
        "row_group_size": row_group_size,
    }

Benchmark Reference Matrix

The following figures reflect typical outcomes on an AWS m5.xlarge (4 vCPU / 16 GB RAM) converting a 2.1 million-row municipal parcel dataset from Shapefile. Numbers vary with geometry complexity and attribute schema.

Configuration Compressed size Write time DuckDB bbox query Memory (peak) Primary use case
ZSTD-3, rg=100k, no sort 480 MB 38 s 4.2 s 3.1 GB Baseline
ZSTD-3, rg=100k, spatial sort 420 MB 42 s 2.8 s 3.1 GB Analytical reads, tight bbox filters
ZSTD-3, rg=200k, spatial sort 415 MB 41 s 3.1 s 5.8 GB Large sequential scans
ZSTD-6, rg=100k, spatial sort 390 MB 71 s 2.8 s 3.1 GB Storage-cost-sensitive, write-once
Snappy, rg=100k, spatial sort 550 MB 36 s 2.9 s 3.1 GB CPU-constrained environments
H3-partitioned, rg=50k 425 MB total 58 s 0.9 s (single H3 cell) 2.2 GB Region-scoped queries

Key observations: spatial sorting reduces query time by ~33% by tightening per-row-group bounding boxes; H3 partitioning cuts single-region query time by ~78% but adds write overhead. ZSTD-6 vs ZSTD-3 saves ~7% space at nearly double the write time — rarely worth it for write-heavy pipelines.


Failure Modes and Gotchas

Missing geo metadata after PyArrow writes. If you convert a GeoDataFrame to an Arrow table with pa.Table.from_pandas() and write it with pq.write_table(), the resulting file lacks the geo metadata key. DuckDB’s spatial extension and tools like QGIS will fail to detect the geometry column or CRS. Always use gdf.to_parquet() or inject metadata manually via geopandas.io.arrow.geopandas_to_arrow.

No column statistics despite write_statistics=True. PyArrow does not compute statistics for columns with the BYTE_ARRAY type that contain arbitrary binary data longer than its internal threshold (~4 KB per value). Very complex polygons with thousands of vertices exceed this as raw WKB. Predicate pushdown based on bbox statistics then falls back to the GeoParquet-level bbox stored in the geo metadata, which applies file-wide rather than per row group. Splitting extremely complex geometries or simplifying them before serialisation restores per-row-group statistics.

CRS mismatch discovered at query time. If source files carry implicit or incorrect CRS metadata and are mixed into the same Parquet dataset without reprojection, spatial joins and intersections will silently produce wrong results. Always validate gdf.crs before writing and store the EPSG code explicitly.

H3 API breakage between v3 and v4. The H3 Python library changed its API in v4.0. The v3 form h3.geo_to_h3(lat, lng, res) was renamed to h3.latlng_to_cell(lat, lng, res) in v4. Code that mixes versions in a dependency tree will raise AttributeError at runtime. Pin explicitly with h3>=4.0,<5.0 or guard with a version check.

Small-file proliferation from over-partitioning. H3 resolution 7 produces ~98 million possible cells globally. For a dataset of 2 million parcels, aggressive resolution means most partitions hold only a few hundred rows and a separate Parquet file per partition. Reading metadata for thousands of micro-files overwhelms the Parquet footer-fetching stage. Use a coarser resolution (5 or 6) or implement a compaction step that merges small partitions until each file reaches 128 MB–1 GB.

Columnar layout is the wrong choice for frequent updates. Parquet is append-optimised. Frequent UPDATE or DELETE operations require rewriting affected row groups entirely, making them prohibitively expensive. For transactional spatial workloads, consider Delta Lake or Apache Iceberg — both layer ACID semantics and row-level change tracking on top of underlying Parquet files.


FAQ

Does Parquet natively store CRS metadata for geospatial data?

Plain Parquet has no built-in CRS concept. The GeoParquet specification adds a geo key to Parquet’s file-level metadata that records the geometry column name, encoding (WKB), and CRS as a PROJJSON object or EPSG code. GeoPandas 0.14+ populates this key automatically; raw PyArrow writes do not.

How large should row groups be for spatial workloads?

100,000–500,000 rows per group covers most spatial vector datasets. Smaller groups improve predicate-pushdown selectivity on tight bounding-box filters but increase file-footer parsing overhead. For DuckDB or Athena scanning from S3, aim for row groups that decompress to roughly 64–256 MB in memory, then reduce the size if your typical queries filter to less than 5% of total rows. The row-group sizing strategies guide has a full tuning workflow.

Can DuckDB query GeoParquet directly from S3 without downloading the file?

Yes. DuckDB’s httpfs extension and native Parquet reader issue HTTP range requests against the S3 object, fetching only the footer and the specific column chunks required. No full download occurs. Load both spatial and httpfs extensions, then call read_parquet('s3://...').

When should I use FlatGeobuf instead of GeoParquet?

FlatGeobuf suits streaming or incremental-write scenarios where individual features are appended frequently and random-access reads to single features matter more than column-level analytics. GeoParquet wins on analytical aggregations over millions of features when most queries touch only a subset of the schema’s columns. For a direct comparison, see comparing GeoParquet vs FlatGeobuf performance.


← Back to Geospatial Storage Fundamentals & Format Comparison

Continue exploring