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.

GeoParquet vs FlatGeobuf: dual-layer architecture GeoParquet lives in the object-storage analytics layer; FlatGeobuf is materialised into the serving layer for low-latency spatial API and browser clients. ANALYTICS LAYER SERVING LAYER GeoParquet Object storage (S3 / GCS) ZSTD · row groups · WKB column Query engines DuckDB · Spark · Polars Predicate pushdown · column pruning ML feature stores · aggregations FlatGeobuf Derived layer · CDN / object store Hilbert R-tree · HTTP Range · binary Spatial clients OGC API Features · tile servers Browser maps · bbox streaming Low-latency spatial filter reads materialise

Production Implementation

The snippet below demonstrates write and read patterns for both formats, covering the settings that carry the most performance weight.

python
# 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:

python
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:

python
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:

bash
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.


← Back to Comparing GeoParquet vs FlatGeobuf Performance