How to Choose Between GeoParquet and FlatGeobuf
Use GeoParquet for cloud-native analytical pipelines, ML feature stores, and distributed columnar queries; use FlatGeobuf for low-latency spatial filtering, HTTP range-request streaming, and web mapping backends. The deciding factor is where query execution happens and what fraction of the dataset it must touch. If your stack runs Spark, DuckDB, or Polars over object storage, GeoParquet is the default. If you serve OGC API Features, dynamic tile servers, or client-side spatial queries driven by bounding box, FlatGeobuf wins. Running both in the same pipeline is not a compromise — it is sound engineering. This guide narrows the decision; the measured numbers behind it live in the parent GeoParquet vs FlatGeobuf performance comparison.
Quick-Reference Format Selection Table
| Workload Pattern | Recommended Format | Primary Use Case | Technical Rationale |
|---|---|---|---|
| Cloud data lake ETL, ML feature store | GeoParquet | Batch analytics, feature engineering | Columnar pruning, ZSTD/Snappy compression, native Spark / DuckDB / Polars support |
| Web tile serving, OGC API Features | FlatGeobuf | Spatial API backends | HTTP range requests, sub-second bbox filtering, low memory overhead |
| Heavy spatial joins and attribute aggregations | GeoParquet | Analytical reporting | Vectorised columnar execution, predicate pushdown on non-geometry columns |
| Serving to browser maps and lightweight clients | FlatGeobuf | Client-side spatial queries | Partial downloads via Range header, embedded Hilbert R-tree avoids full scan |
Why the Architecture Decides the Format
The performance difference between GeoParquet and FlatGeobuf is not a matter of one format being faster in an absolute sense — it is a structural consequence of how each serialises data and how query engines consume it.
GeoParquet inherits Apache Parquet’s row-group layout: the file is divided into chunks of rows, and within each chunk every column is stored contiguously. Geometry is stored as Well-Known Binary (WKB) in a dedicated column, with spatial metadata embedded in Parquet key-value metadata. When a query engine evaluates a filter like population > 50000, it reads statistics in the row-group footer — without deserialising any geometry — and skips entire chunks that cannot satisfy the predicate. This predicate pushdown is what makes GeoParquet fast for analytical workloads: it reduces I/O to the minimum needed, and the columnar layout means engines like DuckDB or Spark decode only the columns the query actually references. Row group sizing directly controls how finely this skipping operates.
FlatGeobuf takes a row-oriented, spatially indexed approach. Every feature is serialised sequentially into the file body, and a packed Hilbert R-tree is prepended as an index. The index maps bounding-box extents to byte offsets within the file body. A client needing features within a geographic window sends one HTTP Range request for the index, identifies matching byte ranges, then issues targeted range requests for only those blocks. This makes FlatGeobuf extraordinarily efficient for spatially selective reads — but it offers no help for attribute-driven filters or multi-column aggregations. Reading population across all features requires a full sequential scan, whereas GeoParquet reads only the population column pages across relevant row groups.
The pattern that emerges is a natural division of labour. GeoParquet belongs at the data platform layer — in object storage, processed by distributed query engines, serving analytical consumers. FlatGeobuf belongs at the serving layer — pre-materialised from curated GeoParquet, streamed over HTTP to API clients or browsers.
Production Implementation
The snippet below demonstrates write and read patterns for both formats, covering the settings that carry the most performance weight.
# Requires: geopandas>=0.14, pyarrow>=14.0, shapely>=2.0, fiona>=1.9 (GDAL>=3.6)
from __future__ import annotations
import time
from pathlib import Path
import geopandas as gpd
import pyarrow.parquet as pq
def write_geoparquet(input_path: str, output_path: str) -> None:
"""Write a vector layer as GeoParquet with ZSTD compression.
row_group_size controls predicate-pushdown granularity: smaller values
improve skip selectivity but increase footer size. 100_000 rows is a
reasonable default for datasets up to ~5 M features on cloud storage.
"""
gdf: gpd.GeoDataFrame = gpd.read_file(input_path)
if gdf.crs is None or gdf.crs.to_epsg() != 4326:
gdf = gdf.to_crs(epsg=4326)
gdf.to_parquet(
output_path,
compression="zstd", # level 3 default; raise to 9 for archival
row_group_size=100_000, # tune for query selectivity
index=False,
)
print(f"Wrote {len(gdf):,} features → {Path(output_path).stat().st_size / 1e6:.1f} MB")
def read_geoparquet_with_pushdown(
path: str,
population_floor: int = 50_000,
) -> gpd.GeoDataFrame:
"""Read GeoParquet with attribute predicate pushdown.
PyArrow evaluates the filter against row-group statistics in the Parquet
footer before loading any pages, eliminating I/O for non-matching chunks.
"""
table = pq.read_table(
path,
columns=["id", "name", "population", "geometry"],
filters=[("population", ">", population_floor)],
)
return gpd.GeoDataFrame(table.to_pandas(), geometry="geometry", crs="EPSG:4326")
def write_flatgeobuf(gdf: gpd.GeoDataFrame, output_path: str) -> None:
"""Write GeoDataFrame as FlatGeobuf with the Hilbert R-tree index.
The GDAL FlatGeobuf driver builds the spatial index automatically.
Omitting the index removes the primary performance advantage.
"""
if gdf.crs is None or gdf.crs.to_epsg() != 4326:
gdf = gdf.to_crs(epsg=4326)
gdf.to_file(output_path, driver="FlatGeobuf")
print(f"Wrote {len(gdf):,} features → {Path(output_path).stat().st_size / 1e6:.1f} MB")
def read_flatgeobuf_bbox(
path: str,
min_lon: float, min_lat: float,
max_lon: float, max_lat: float,
) -> gpd.GeoDataFrame:
"""Read only features within a bounding box using the embedded index.
On a remote HTTP source the GDAL driver translates bbox reads directly
to HTTP Range requests — use /vsicurl/ prefix for S3/GCS URLs.
"""
start = time.perf_counter()
result: gpd.GeoDataFrame = gpd.read_file(path, bbox=(min_lon, min_lat, max_lon, max_lat))
print(f"Bbox filter returned {len(result):,} features in {time.perf_counter() - start:.3f}s")
return result
For HTTP range-request streaming from object storage, serve the FlatGeobuf with Accept-Ranges: bytes and Content-Length headers, then read it via the GDAL /vsicurl/ virtual filesystem: gpd.read_file("/vsicurl/https://bucket.s3.amazonaws.com/layer.fgb", bbox=bbox).
Validation and Verification
Before committing either format to a production pipeline, run lightweight smoke tests to confirm the file is well-formed and the performance-critical features are active.
GeoParquet validation:
import pyarrow.parquet as pq
meta = pq.read_metadata("output.parquet")
print(f"Row groups: {meta.num_row_groups}") # should be > 1 for large files
print(f"Rows: {meta.num_rows:,}")
schema = pq.read_schema("output.parquet")
assert b"geo" in schema.metadata, "Missing GeoParquet metadata — file may not be spec-compliant"
FlatGeobuf validation:
import geopandas as gpd, time
start = time.perf_counter()
gdf = gpd.read_file("output.fgb", bbox=(-10, 35, 30, 70)) # rough EU bbox
print(f"Bbox read: {len(gdf):,} features in {time.perf_counter() - start:.3f}s")
# Expect < 0.5 s for datasets under 500 MB when the index is present;
# a full sequential scan (missing index) takes several seconds.
For cloud deployments, verify that your object storage gateway responds with the Accept-Ranges: bytes header — its absence forces GDAL to download the full file before reading:
curl -I "https://your-bucket.s3.amazonaws.com/layer.fgb" | grep -i "accept-ranges"
# Expected: accept-ranges: bytes
Edge Cases and Caveats
Very large files with dense spatial index. FlatGeobuf’s Hilbert R-tree grows with feature count. For datasets exceeding several million features, the index itself can reach tens of megabytes, making the initial range request to fetch it a non-trivial overhead if the API consistently returns only a handful of features per query. In this scenario, consider spatially partitioning the dataset into multiple FlatGeobuf files by tile or administrative boundary, so index size stays proportionate to the typical query footprint.
Mixed or unknown CRS sources. GeoParquet stores CRS metadata in the geo key-value entry and uses it to validate reads. Concatenating GeoParquet files from different sources without normalising CRS first can cause query engines to silently misalign coordinate frames during spatial joins — producing geographically wrong results without raising an error. Always project to a canonical CRS (typically EPSG:4326 for interchange) before writing. Pipelines consuming Shapefile-sourced data require extra care because Shapefiles often carry ambiguous or missing projection files.
Append workloads. Neither format supports true in-place feature appends efficiently, but the consequences differ. Appending to GeoParquet is straightforward: write a new file and read both as a dataset with pyarrow.dataset. Appending to FlatGeobuf invalidates the spatial index, requiring a full rewrite or index rebuild via ogr2ogr. For incremental writes — such as streaming sensor data with a spatial component — write micro-batches as GeoParquet and materialise a consolidated FlatGeobuf on a periodic schedule, using ZSTD compression levels tuned for the target file size.
Frequently Asked Questions
Can I use GeoParquet and FlatGeobuf together in the same pipeline?
Yes — and it is a common production pattern. Store curated layers as GeoParquet in object storage for batch analytics and ML feature extraction, then materialise FlatGeobuf derivatives for API serving or browser-side spatial queries. The two formats are interoperable via GeoPandas and GDAL, so conversion between them is a single line of code.
Does FlatGeobuf support cloud-native range requests the same way GeoParquet does?
FlatGeobuf’s embedded Hilbert R-tree index maps spatial extents to byte offsets, enabling HTTP Range requests that fetch only the index header and matching feature blocks. GeoParquet achieves a similar effect at the row-group level via Parquet’s footer metadata. The key difference is granularity: FlatGeobuf range reads are driven by spatial extent; GeoParquet range reads are driven by row-group boundaries set at write time.
When does the format choice not matter much?
For small datasets under roughly 50 MB, read latency differences between the two formats are usually negligible at the application level. Format choice matters most when you are reading tens or hundreds of millions of features, running distributed query engines, or serving sub-second spatial queries at scale.
What happens if I build a FlatGeobuf file without the spatial index?
A FlatGeobuf file written without the index loses its primary performance advantage. Bounding-box queries must scan every feature sequentially rather than jumping directly to the relevant byte range. Always build the index at write time unless you have a specific reason to omit it, such as an append-only stream where you plan to rebuild the index in a separate pass.
Related
- Comparing GeoParquet vs FlatGeobuf Performance — benchmark results covering I/O latency, CPU utilisation, and memory footprint
- Understanding Parquet Columnar Storage for GIS — how the columnar layout underpins GeoParquet’s analytical performance
- Row Group Sizing Strategies for Parquet — tuning the parameter that controls GeoParquet’s predicate pushdown granularity
- ZSTD Compression Levels for Geospatial Data — choosing the right ZSTD level for GeoParquet write-time compression
- Shapefile Limitations in Modern Data Stacks — why both formats exist as successors to legacy Shapefile workflows