GeoPackage vs FlatGeobuf for Reporting Pipelines

GeoPackage and FlatGeobuf are the two binary vector formats worth defaulting to when GeoJSON’s full-file parse becomes a bottleneck, but they are built on opposite assumptions. GeoPackage is a SQLite database: random-access, multi-layer, transactionally editable. FlatGeobuf is a flat buffer of length-prefixed features behind a packed spatial index: streamable, cloud-native, append-only. This guide picks between them for the specific job of feeding an automated report, and sits under the broader Geospatial Data Formats for Reporting Pipelines overview. If neither binary format is on the table yet, start instead with Choosing GeoJSON vs GeoPackage for Report Inputs.

Prerequisites

  • Python 3.10+ with geopandas>=0.14, pyogrio>=0.7, and fiona>=1.9
  • GDAL 3.6+ (both formats are GDAL drivers; FlatGeobuf /vsicurl/ reads need a recent build)
  • A sample dataset available in both .gpkg and .fgb so you can compare read paths on identical geometry
  • Working knowledge of the ingestion funnel described in the parent Geospatial Data Formats for Reporting Pipelines guide

How the Two Formats Differ

The decision tree below reduces the choice to three questions: does the report need to read a small extent from a large or remote file, does the pipeline mutate the data, and does the input hold multiple layers.

GeoPackage vs FlatGeobuf Decision Flow A three-question decision flow. If the report reads a bounded extent from a large or remote file, prefer FlatGeobuf. If the pipeline edits data in place or needs multiple layers or non-spatial tables, prefer GeoPackage. Bounded read from a large or remote file? yes FlatGeobuf index + HTTP range no Edit rows in place, or multiple layers / tables? yes GeoPackage SQLite random access no Either works Prefer FlatGeobuf for a single immutable read-only layer Both are binary and spatially indexed; the split is access pattern, not raw speed.

Comparison Table

Dimension GeoPackage FlatGeobuf
Container model SQLite database file (.gpkg) Flat binary buffer (.fgb)
Access pattern Random access by feature ID or SQL query Sequential stream, index-seekable by extent
Spatial index R-tree, stored as SQLite virtual table Packed Hilbert R-tree in the file header
Multiple layers Yes — many vector and attribute tables per file No — exactly one feature collection per file
Non-spatial tables Yes — attribute-only tables and relations No
Editability Full insert/update/delete in a transaction Append-only; no in-place edit
CRS support Any CRS, per layer Any CRS, single per file
Remote reads Whole file must be local (SQLite needs a file handle) HTTP range reads via /vsicurl/ without full download
Write cost Higher — journaled transactions Lower — sequential append
Best report role Curated master store queried per report Immutable extract streamed into one report

When to Choose GeoPackage

Reach for GeoPackage when the report input is a living master dataset. Because it is SQLite, you can open one file, list its layers, and pull exactly the table a given report needs without touching the others — the multi-layer read described in the parent guide. It also supports attribute-only tables, so lookup dictionaries and feature-to-feature relations live alongside geometry in the same container.

Python
# gpkg_query.py — pull one layer and count features by status
from __future__ import annotations

import geopandas as gpd
import pyogrio

def read_status(path: str, layer: str) -> gpd.GeoDataFrame:
    layers = [name for name, _ in pyogrio.list_layers(path)]
    if layer not in layers:
        raise KeyError(f"{layer!r} not in {path}; available: {layers}")
    return gpd.read_file(path, layer=layer, engine="pyogrio")

Random access is the deciding property. If a report is generated repeatedly against a dataset that analysts keep editing in QGIS between runs, GeoPackage lets those edits land in place and the next report read picks them up transactionally. FlatGeobuf, being append-only, would force you to rewrite the whole file for every change.

When to Choose FlatGeobuf

Reach for FlatGeobuf when the report reads a bounded extent from a large or remote extract. Its packed Hilbert R-tree sits in the file header, so a bounding-box read seeks straight to the relevant byte ranges instead of scanning. Over HTTP, GDAL’s /vsicurl/ handler turns that into range requests: the reader fetches the header and index, then only the bytes covering your extent.

Python
# fgb_extent.py — read a bbox from a remote FlatGeobuf without downloading it
from __future__ import annotations

import geopandas as gpd

def read_remote_extent(
    url: str, bbox: tuple[float, float, float, float]
) -> gpd.GeoDataFrame:
    vsi = f"/vsicurl/{url}"
    return gpd.read_file(vsi, bbox=bbox, engine="pyogrio")

This is the property that makes FlatGeobuf the natural input for the incremental read covered in Streaming FlatGeobuf Features into Report Context: you can stream a district out of a national file that never fully lands on the report worker’s disk.

Read and Query Benchmark Framing

Do not trust a single stopwatch number — benchmark the access pattern the report actually uses. A fair comparison holds geometry constant and varies only the read:

Python
# bench.py — compare full read vs bbox read on identical geometry
from __future__ import annotations

import time
import geopandas as gpd

def timed_read(path: str, bbox=None, layer=None) -> tuple[int, float]:
    start = time.perf_counter()
    gdf = gpd.read_file(path, bbox=bbox, layer=layer, engine="pyogrio")
    return len(gdf), time.perf_counter() - start

The result you should expect: for a full read, GeoPackage and FlatGeobuf land close together, both far ahead of GeoJSON. For a small bbox read against a large file, FlatGeobuf pulls ahead because its index-seek avoids reading untouched features, and the gap widens dramatically over HTTP where GeoPackage cannot range-read at all. Run the benchmark on your own data volumes before standardising a format — feature width and geometry complexity shift the crossover point.

Key Parameters / Configuration Reference

Spec Value Notes
GeoPackage extension .gpkg Single SQLite file; may hold many layers
FlatGeobuf extension .fgb Single feature collection per file
engine "pyogrio" Vectorised default for full-layer reads
bbox (minx, miny, maxx, maxy) Must be in the layer’s native CRS
layer str Required for GeoPackage; ignored for FlatGeobuf
Remote prefix /vsicurl/<url> FlatGeobuf only; host must send Accept-Ranges: bytes

Common Pitfalls

  • Expecting HTTP range reads from GeoPackage. SQLite needs a seekable local file handle; pointing /vsicurl/ at a .gpkg downloads the whole database. Use FlatGeobuf for remote extent reads.
  • Editing a FlatGeobuf in place. It is append-only. Rewrite the entire file to change a row, or keep the editable master as GeoPackage and export a fresh .fgb extract per publish.
  • Omitting layer on a GeoPackage. Multi-layer containers read the first layer silently when layer is unset — list layers first and name the one you want.
  • Assuming FlatGeobuf’s index helps a full read. The Hilbert index accelerates extent queries, not whole-file scans; a full read is bound by raw I/O, where GeoPackage is competitive.

Verification

Confirm your format choice behaves as expected before wiring it into the pipeline:

Python
# verify_choice.py
import pyogrio

# GeoPackage must expose the expected layer
gpkg_layers = [n for n, _ in pyogrio.list_layers("master.gpkg")]
assert "parcels" in gpkg_layers, gpkg_layers

# FlatGeobuf is single-layer by definition
fgb_layers = pyogrio.list_layers("extract.fgb")
assert len(fgb_layers) == 1, "FlatGeobuf must hold exactly one layer"
print("Format contracts verified")

In CI, add a range-read smoke test against the remote FlatGeobuf host and assert it returns fewer features than the full count — proof the index seek is working rather than a silent full download.