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, andfiona>=1.9 - GDAL 3.6+ (both formats are GDAL drivers; FlatGeobuf
/vsicurl/reads need a recent build) - A sample dataset available in both
.gpkgand.fgbso 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.
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.
# 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.
# 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:
# 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.gpkgdownloads 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
.fgbextract per publish. - Omitting
layeron a GeoPackage. Multi-layer containers read the first layer silently whenlayeris 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:
# 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.
Related
- Streaming FlatGeobuf Features into Report Context — put FlatGeobuf’s range reads to work with a memory-safe incremental reader
- Choosing GeoJSON vs GeoPackage for Report Inputs — the earlier decision, before either binary format is in play
- Automated Static Map Generation from GeoJSON — render the extract you chose here into a static map
- Parent: Geospatial Data Formats for Reporting Pipelines