Using GeoParquet for Large Spatial Datasets
TL;DR: Write with gdf.to_parquet("data.parquet", geometry_encoding="WKB", write_covering_bbox=True) and read a subset with gpd.read_parquet("data.parquet", bbox=(minx, miny, maxx, maxy)). For attribute pruning add filters=[("landuse", "==", "forest")]. For datasets that do not fit in memory, write a partitioned PyArrow dataset with partition_cols and let bounding-box plus predicate pushdown skip whole files and row groups before anything is decoded.
Why This Matters
Shapefile and GeoJSON force a full parse of every feature before you can touch a single record, they store geometry row by row, and Shapefile additionally caps files at 2 GB and field names at ten characters. For the multi-million-feature layers common in geostatistical work — parcel databases, sensor archives, gridded prediction points — that model collapses. GeoParquet stores geometry and attributes columnarly, compressed, with per-row-group statistics, so a reader can prune by spatial extent and by attribute value before decoding. This page applies the lazy, read-only-what-you-need philosophy of the Memory-Efficient Processing guide to on-disk storage, and it underpins every ingestion step in the wider Python Workflows for Spatial Modeling & Regression stack.
The payoff is concrete: a spatial query over a national point layer that took minutes to scan as GeoJSON becomes a sub-second read that touches only the row groups intersecting your window. Combined with partitioning, it lets you keep terabyte-scale archives on disk or object storage and pull working slices into RAM on demand.
Environment
pip install \
geopandas==0.14.4 \
pyarrow==15.0.2 \
shapely==2.0.4 \
pyproj==3.6.1 \
numpy==1.26.4
import numpy as np
import geopandas as gpd
import pyarrow as pa
import pyarrow.parquet as pq
import pyarrow.dataset as ds
from shapely.geometry import box
GeoParquet 1.1 bounding-box support requires geopandas>=0.14 and pyarrow>=13. Confirm versions before relying on bbox= pushdown.
Step-by-Step Implementation
Step 1 — Write a Single GeoParquet File
GeoDataFrame.to_parquet serialises geometry as WKB and records CRS and schema in the file metadata. Sort features spatially first so consecutive rows are geographically close, which makes row-group bounding boxes tight and prunable.
gdf = gpd.read_file("sensors.gpkg") # any source
gdf = gdf.to_crs(3857) # a projected CRS for tidy bboxes
# Spatial sort by Hilbert distance so row groups are compact blocks
gdf = gdf.sort_values(by=gdf.geometry.hilbert_distance()).reset_index(drop=True)
gdf.to_parquet(
"sensors.parquet",
geometry_encoding="WKB",
write_covering_bbox=True, # per-row-group bbox statistics for pushdown
row_group_size=100_000, # features per independently prunable block
compression="zstd",
)
write_covering_bbox=True adds the covering-bbox column that makes spatial pushdown possible. Without it, read_parquet(bbox=...) must fall back to reading everything.
Step 2 — Partition a Larger-than-Memory Dataset
When the source itself does not fit in RAM, write a partitioned dataset: many files under a directory, split by a categorical or spatial-tile column. Readers skip whole partitions whose value cannot match the query.
gdf["tile"] = (gdf.geometry.x // 100_000).astype("int32").astype(str) + "_" \
+ (gdf.geometry.y // 100_000).astype("int32").astype(str)
table = pa.Table.from_pandas(gdf.to_wkb()) # geometry as WKB columns
pq.write_to_dataset(
table,
root_path="sensors_ds",
partition_cols=["tile"],
row_group_size=100_000,
)
This lays out sensors_ds/tile=0_0/…, sensors_ds/tile=1_0/… and so on. A query restricted to one tile opens only that subdirectory.
Step 3 — Read with Bounding-Box Pushdown
Pass a bbox tuple to read_parquet. The reader compares your window against each row group’s covering bbox and decodes only intersecting groups.
query_window = (400_000, 5_600_000, 450_000, 5_650_000) # minx, miny, maxx, maxy
subset = gpd.read_parquet("sensors.parquet", bbox=query_window)
print(f"Read {len(subset):,} of {len(gdf):,} features")
Only the row groups overlapping query_window are touched. On a spatially sorted file this typically reads a few percent of the data for a local query.
Step 4 — Add Predicate Pushdown on Attributes
Attribute filters are evaluated against per-row-group min/max statistics, so groups that cannot satisfy the predicate are skipped without decoding. Combine spatial and attribute pushdown for the tightest read.
subset = gpd.read_parquet(
"sensors.parquet",
bbox=query_window,
filters=[("status", "==", "active"), ("pm25", ">", 15.0)],
columns=["geometry", "station_id", "pm25"], # column projection
)
columns= is the third lever: reading only the columns you model, alongside bbox and predicate pushdown, minimises bytes decoded on all three axes — extent, rows, and columns.
Step 5 — Query a Partitioned Dataset
For the partitioned layout, open it as a PyArrow dataset and let both partition pruning and row-group statistics apply. Convert the result back to a GeoDataFrame.
dataset = ds.dataset("sensors_ds", format="parquet", partitioning="hive")
filtered = dataset.to_table(
filter=(ds.field("tile") == "4_56") & (ds.field("pm25") > 15.0),
columns=["geometry", "station_id", "pm25"],
)
subset = gpd.GeoDataFrame.from_arrow(filtered).set_crs(3857)
print(subset.shape)
The tile == "4_56" predicate prunes at the directory level; pm25 > 15.0 prunes at the row-group level within the surviving files.
Interpreting the Output
The headline signal is the ratio of features read to features stored: len(subset) / len(gdf). For a local spatial query on a spatially sorted file it should be a small fraction — if it approaches 1.0, pushdown is not engaging. The two usual causes are a missing covering-bbox column (rewrite with write_covering_bbox=True) or an unsorted file where every row group spans the whole extent, making every group intersect every query. Inspect grouping with pq.ParquetFile("sensors.parquet").metadata, which reports num_row_groups and per-group row counts; a single giant row group cannot be pruned at all.
For partitioned datasets, list the touched files during a read (PyArrow exposes fragment metadata) to confirm partition pruning skipped the directories you expect. A query that opens every partition means the partition key is not aligned to your query pattern.
Critical Best Practices
Always Write a Covering Bbox and Sort Spatially
Bbox pushdown depends on two things: the covering-bbox column existing, and row groups being geographically compact. Writing without write_covering_bbox=True, or writing in arbitrary row order, leaves you with pruning that never fires. Sort by hilbert_distance() (or GeoHash, or tile id) before writing so each row group covers a small, non-overlapping patch.
Use a Projected CRS for Storage
Bounding boxes in degrees are awkward near the antimeridian and poles, and mixing geographic coordinates with metric query windows silently returns nothing. Store large layers in a projected CRS and document it; downstream distance and buffer work needs metric units anyway, as covered in Reprojecting CRS for Accurate Distance Calculations.
Right-Size Row Groups and Partitions
Row groups near 50,000–200,000 features balance pruning granularity against metadata overhead. Partitions should map to your query pattern: if you always query one region at a time, partition by spatial tile; if you always query one date, partition by date. Over-partitioning creates thousands of tiny files that hurt object-storage listing performance.
Prefer GeoParquet over Shapefile at Every Boundary
Every place a pipeline reads or writes a Shapefile is a place it truncates field names, loses type fidelity, and forces a full parse. Convert legacy Shapefiles to GeoParquet once at ingestion. This dovetails with the profiling and lazy-read patterns in the parent Memory-Efficient Processing guide.
Combine Column, Row, and Extent Projection
The cheapest read narrows on all three axes at once: columns= for the fields you model, filters= for attribute predicates, and bbox= for extent. Reaching for only one leaves bytes on the table. Profile the decoded size, not the file size, to see the effect.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
bbox= read returns the whole file |
No covering-bbox column written | Rewrite with write_covering_bbox=True on geopandas>=0.14 |
| Pushdown reads almost everything | File not spatially sorted; row groups overlap globally | Sort by hilbert_distance() before to_parquet |
| Empty result for a valid area | Query window CRS differs from stored CRS | Reproject the bbox to the file’s CRS before querying |
filters= has no effect |
Predicate column not in row-group statistics (e.g. after casting) | Ensure the column is a native Arrow type; avoid object dtype |
| Slow reads from object storage | Thousands of tiny partition files | Coarsen partition_cols; increase row_group_size |
CRS missing after from_arrow |
Arrow table dropped GeoParquet metadata | Re-attach with .set_crs(...) matching the write CRS |
Next Steps
GeoParquet is the durable sink for the tiled outputs produced upstream; see Chunked Raster Processing with Dask-GeoPandas for generating partitioned zonal results, and return to the parent Memory-Efficient Processing guide for the downcasting and sparse-weight techniques that pair with columnar storage.
Related:
- Chunked Raster Processing with Dask-GeoPandas — produces the partitioned tabular output GeoParquet is built to store
- Reducing Memory Bottlenecks in Geospatial Workflows — downcasting and lazy evaluation that compound with columnar pruning
- Optimizing GeoPandas Spatial Joins for Large Datasets — index strategies for the frames you load from GeoParquet
← Back to Memory-Efficient Processing