Converting GeoPackage to FlatGeobuf for Web Archives

Converting GeoPackage to FlatGeobuf turns a multi-layer SQLite container into one range-readable .fgb file per layer, the form a read-mostly web-map archive needs to serve features straight from object storage without a database engine in the path. This guide is for GIS archivists and data engineers who receive vector deliverables as .gpkg bundles and must republish them as cold-storage-friendly web assets while preserving layer boundaries, per-layer coordinate reference systems (CRS), and feature counts exactly. It covers the decision — when a FlatGeobuf split genuinely beats keeping the GeoPackage — plus per-layer and CRS handling, a batch ogr2ogr conversion loop, and the parity checks that prove nothing was dropped. It applies the packed-index discipline from FlatGeobuf Optimization Techniques inside the broader Format Conversion & Pipeline Automation workflow.

GeoPackage Versus FlatGeobuf for Cold Web Delivery

The two formats solve different problems; the split only pays off when the access pattern is read-mostly delivery from object storage:

GeoPackage versus FlatGeobuf comparison matrix Five dimensions compared. FlatGeobuf wins for cold web delivery: native HTTP range reads, one layer per file, an in-file Hilbert R-tree. GeoPackage wins for multi-layer bundling and in-place editing but needs a database engine to read. Dimension GeoPackage (.gpkg) FlatGeobuf (.fgb) Range read from object store page-by-page random I/O native, packed R-tree Layers per file many (SQLite container) one layer per file In-place edits read/write, transactions write-once, immutable Cold web delivery fit weak — needs a DB engine strong — bytes over HTTP Spatial index R-tree inside SQLite Hilbert R-tree in the file

GeoPackage is a full SQLite database: one file can carry dozens of layers, an R-tree index per geometry column, styles, and metadata tables, and it supports transactional in-place editing. Those are exactly the properties a desktop editing or field-collection workflow wants. But reading a .gpkg means running a SQLite engine that issues many small, dependent page reads. Over a local disk that is invisible; over HTTP from cold object storage it is a chatty, latency-bound access pattern, and most cloud consumers simply download the entire database first. FlatGeobuf inverts the trade: it drops multi-layer bundling and editability, and in return each layer becomes a single self-describing file whose packed Hilbert R-tree lets a browser range-read only the features it needs — the mechanism detailed in streaming FlatGeobuf features over HTTP range requests.

The decision follows the access pattern. Keep the GeoPackage as the archival master and editing surface when the workflow still writes to it, when a single portable multi-layer file matters, or when the consumer is desktop GIS. Publish a FlatGeobuf derivative per layer when the archive is read-mostly, served to web clients, and parked in a warm or cold tier where a full-database download would blow the egress budget. For heavy analytical scans over attributes rather than spatial windows, neither wins — that workload belongs to the columnar GeoParquet Migration Workflows path, and a mature archive keeps both derivatives behind one manifest.

Treat the conversion as a derivation, not a replacement. The GeoPackage remains the source of truth and the object you retain under a retention schedule; the FlatGeobuf set is a disposable, regenerable delivery projection you can rebuild from the master at any time. That framing matters for two operational reasons. First, it decouples the delivery format’s lifecycle from the archive’s: you can re-split with a newer GDAL, a different delivery CRS, or a pruned attribute set without touching the record copy. Second, it clarifies where the conversion belongs in a tiered layout — the .gpkg master can sit in a colder, cheaper tier because it is read only when the derivative is regenerated, while the .fgb layers occupy the warm, range-served tier that actually faces traffic. Size the split accordingly: a GeoPackage carrying twenty small thematic layers becomes twenty small objects, and very small .fgb files may fall below a store’s minimum billable object size, so consolidate thin layers or accept the floor before generating hundreds of sub-kilobyte files.

Converting a GeoPackage to Indexed FlatGeobuf

A GeoPackage may hold many layers on different CRSes and even aspatial attribute tables. The steps below enumerate the container, convert each spatial layer to its own indexed .fgb, and normalize projection along the way.

  1. Enumerate layers and their CRSes. FlatGeobuf is single-layer, so you must know every layer name and geometry type before splitting. List them without reading features.
# Summarize every layer, its geometry type, and its CRS.
ogrinfo -so datasets/basemaps/city_base.gpkg
# Layer: parcels  (Polygon)   SRS: EPSG:2913
# Layer: roads    (LineString) SRS: EPSG:2913
# Layer: hydro    (MultiPolygon) SRS: EPSG:4326
# Layer: address_points (Point) SRS: EPSG:2913
# Table: layer_styles  (non-spatial — skip)
  1. Convert one layer with an explicit target CRS and a packed index. Web-map archives standardize on a single delivery CRS — EPSG:4326 for raw coordinates or EPSG:3857 for pre-projected tiles — so reproject during the split. Enable the spatial index in the same pass; the projection-registry rules behind this belong to CRS synchronization in pipelines.
# One GPKG layer -> one indexed FlatGeobuf, reprojected to the web CRS.
ogr2ogr -f "FlatGeobuf" \
  datasets/basemaps/fgb/parcels.fgb \
  datasets/basemaps/city_base.gpkg parcels \
  -t_srs EPSG:4326 \
  -lco SPATIAL_INDEX=YES \
  -nln parcels \
  -nlt PROMOTE_TO_MULTI

PROMOTE_TO_MULTI guards against a layer that mixes Polygon and MultiPolygon; FlatGeobuf stores a single geometry type per file, so promoting to the multi form keeps the whole layer in one valid file.

  1. Batch every spatial layer in the container. Drive the split from the enumerated layer list, skip non-spatial tables, and index each output. Emit to a per-layer prefix in the target archive.
# Convert all spatial layers; aspatial tables (no geometry) are skipped by name.
LAYERS=$(ogrinfo -so -q datasets/basemaps/city_base.gpkg \
  | awk '/^Layer name:/ {print $3}')

for layer in $LAYERS; do
  [ "$layer" = "layer_styles" ] && continue   # non-spatial metadata table
  ogr2ogr -f "FlatGeobuf" \
    "s3://spatial-archive/fgb/basemaps/${layer}.fgb" \
    datasets/basemaps/city_base.gpkg "$layer" \
    -t_srs EPSG:4326 \
    -lco SPATIAL_INDEX=YES \
    -nln "$layer" \
    -nlt PROMOTE_TO_MULTI \
    -skipfailures
  echo "converted ${layer}"
done

What Survives the Conversion and What Does Not

GeoPackage is a SQLite database; FlatGeobuf is a flat record stream. The conversion therefore drops everything that depended on being in a database, and a pipeline that does not enumerate those losses in advance discovers them when a user reports a missing layer.

What a GeoPackage to FlatGeobuf conversion keeps and loses A two-column comparison. The left column lists preserved content: geometry, attributes, reference system, feature counts and extents. The right column lists discarded content: multi-layer containers, attribute indexes, triggers and constraints, styling tables, relationships and views. preserved by the conversion Geometry, exactly — WKB in, WKB out All attribute columns and their values Coordinate reference system, in the header Feature count and layer extent Field names, subject to no length limit Column order, if the writer is told to keep it discarded — one file, one layer Multi-layer containers → one file per layer Attribute indexes → no attribute lookup Triggers, constraints, foreign keys Styling and symbology tables Relationship tables and SQL views Editable state — the output is immutable

The multi-layer point is the one that reshapes the pipeline. A GeoPackage holding twelve thematic layers becomes twelve FlatGeobuf files, each needing its own name, its own index, and its own entry in whatever catalogue serves the archive — so the conversion job is a fan-out, not a one-to-one transform, and the naming convention it uses becomes part of the archive’s contract with its readers. Decide it once and record it in the dataset manifest, because renaming later breaks every URL a map client has cached.

Validation & Verification

The conversion is only trustworthy if every layer’s feature count survived and every output carries a spatial index. Reconcile source against derivative per layer, then confirm the index.

# Per-layer parity: source GPKG count must equal the FlatGeobuf count.
for layer in parcels roads hydro address_points; do
  src=$(ogrinfo -so -q datasets/basemaps/city_base.gpkg "$layer" \
    | awk '/Feature Count/ {print $3}')
  dst=$(ogrinfo -so -q "datasets/basemaps/fgb/${layer}.fgb" \
    | awk '/Feature Count/ {print $3}')
  if [ "$src" = "$dst" ]; then echo "$layer OK ($src)"; \
  else echo "$layer MISMATCH src=$src dst=$dst"; fi
done

Expected output — every layer reconciles exactly, or the mismatch is surfaced for triage:

parcels OK (482817)
roads OK (91245)
hydro OK (12830)
address_points OK (655102)

Then prove each output is genuinely range-readable by confirming the packed R-tree is present. An index-less .fgb is a valid file that forces every web client into a full scan:

# Confirm the packed spatial index exists on each converted layer.
from flatgeobuf import HeaderReader  # pip install flatgeobuf
import glob

for path in glob.glob("datasets/basemaps/fgb/*.fgb"):
    with open(path, "rb") as f:
        h = HeaderReader.read(f)
    assert h.index_node_size > 0, f"{path} has NO spatial index"
    assert "4326" in (h.crs.code_string or str(h.crs.code)), f"{path} wrong CRS"
    print(f"{path}: index ok, {h.features_count} features, EPSG:4326")

Sizing the Fan-Out Job

Because one GeoPackage becomes many FlatGeobuf files, the conversion is a fan-out whose cost is dominated by the largest layer rather than by the container. Scheduling it as a single sequential job wastes most of the available parallelism; scheduling one worker per layer wastes capacity on the many small ones.

Layer sizes and the three ways to schedule the conversion A size distribution across twelve layers of one GeoPackage, dominated by a single 11 gigabyte layer, and the elapsed time of three scheduling strategies: sequential at 84 minutes, one worker per layer at 39 minutes, and splitting the largest layer at 14 minutes. One GeoPackage · 12 layers · 18.6 GB parcels 11.0 GB buildings 4.2 GB roads 1.8 GB 9 thematic layers 40–600 MB each sequential 84 min one worker per layer 39 min — bounded by parcels split parcels × 4 14 min — bounded by buildings

The rule that falls out: parallelise across layers first, then split any layer whose own conversion exceeds the target window. Splitting means converting spatial subsets and publishing them as separate files, which the client-side manifest already supports — so the scheduling decision and the delivery layout are the same decision, made once.

Troubleshooting

Symptom Root cause Fix
ogr2ogr errors with layer has no geometry column on some tables The GeoPackage holds aspatial attribute or style tables (layer_styles, gpkg_metadata) that FlatGeobuf cannot represent Skip tables with no geometry in the batch loop; convert only layers ogrinfo reports with a geometry type
Converted layer renders offset or in the wrong place on the web map Source layers were on mixed CRSes (some EPSG:2913, some EPSG:4326) and one was not reprojected Apply -t_srs EPSG:4326 (or EPSG:3857) to every layer so all outputs share one delivery CRS
Mixed geometry types or Unsupported geometry type during write A GPKG layer mixes single and multi geometries, or contains curved CircularString types FlatGeobuf lacks Add -nlt PROMOTE_TO_MULTI, and for curves add -nlt CONVERT_TO_LINEAR to linearize before writing

Consult the GDAL FlatGeobuf driver documentation and GDAL GeoPackage driver documentation for the exact geometry-type and layer-creation matrices, the OGC GeoPackage standard for the source container semantics, and the FlatGeobuf specification for the target encoding.

Operational Execution Checklist

Frequently Asked Questions

Should the GeoPackage be kept after conversion?

Keep it if it is the authoritative source, and tier it rather than delete it. The FlatGeobuf copy is a derivative optimised for delivery; regenerating it is cheap, whereas recovering an editable GeoPackage from flat records means rebuilding indexes, constraints, and any relationships the conversion dropped. The usual arrangement puts the source in a deep archive class and the derived copy in a class that supports range reads.

How large should a single FlatGeobuf file be?

For web delivery, small enough that the index levels stay shallow and large enough that a client is not stitching dozens of files together — a few hundred megabytes to a couple of gigabytes covers most thematic layers. Where a layer is far larger, split it spatially rather than arbitrarily, so that a client can select the one file its viewport needs from a small directory rather than probing several.

Does the conversion need a spatial sort if the GeoPackage was already indexed?

Yes. A GeoPackage’s R-tree index makes queries fast inside the database but says nothing about the physical order of rows in the table, and the conversion reads rows in whatever order the query returns them. Sorting explicitly during the conversion is what makes the resulting file’s features contiguous for a bounding-box query, which is the property the delivery copy exists to provide.

Part of the Spatial Data Archival knowledge base.