Testing GeoPackage vs FlatGeobuf Round Trips
GeoPackage is a SQLite database with a spatial contract; FlatGeobuf is a flat, streamable buffer with a fixed schema and an optional packed R-tree. They store nearly the same information and they disagree in ways that a naive comparison either misses entirely or reports as failure when it is not. This guide sits within cross-format parity testing and covers writing a round-trip test that distinguishes the two.
The premise worth stating plainly: a round trip is not expected to be lossless. It is expected to be lossless in the properties your pipeline depends on, and the job of the test is to write those properties down.
Root cause: two different data models, one comparison
A round trip means writing a GeoDataFrame to one format, reading it back, and comparing. The comparison fails for three quite different reasons, and treating them alike is what makes these tests either flaky or useless.
Most failing round-trip suites fail on the left column and get deleted. A suite that normalises representation, asserts capability, and fails only on semantics is one that keeps earning its runtime.
Where the two formats actually differ
| Property | GeoPackage | FlatGeobuf | Consequence for a test |
|---|---|---|---|
| Layers per file | Many | One | Multi-layer round trips need one file per layer |
| Geometry types | Mixed allowed per layer | Declared once in the header | Mixed input silently promotes or fails |
| Attribute nulls | Full SQL null support | Present, but readers vary | Assert nulls explicitly on both sides |
| Column types | SQLite affinity, loose | Fixed typed columns | Integer widths can change on the way back |
| Spatial index | R-tree in a side table | Packed Hilbert R-tree inline | Index presence is not a data property |
| Appending | Supported | Not supported | Append tests belong to GeoPackage only |
| Streaming reads | No | Yes | Only FlatGeobuf supports partial reads |
The geometry-type row is the one that produces the most surprising failures. FlatGeobuf writes the geometry type into its header, so a layer containing both Polygon and MultiPolygon is written with a single declared type and every feature is coerced to it. Read back, all your polygons are multipolygons — a representation change that is easy to mistake for a semantic one, and vice versa.
Step-by-step implementation
Step 1 — Build a fixture that contains the hard cases
A round-trip test over ordinary data proves very little. The fixture should carry the cases the formats disagree about:
import geopandas as gpd
import numpy as np
import pandas as pd
from shapely.geometry import Point, Polygon, MultiPolygon
from shapely import wkt
def parity_fixture() -> gpd.GeoDataFrame:
"""A small layer holding every property the two formats can disagree about."""
geoms = [
Point(0.0, 0.0),
Polygon([(0, 0), (1, 0), (1, 1), (0, 1)]),
MultiPolygon([Polygon([(2, 2), (3, 2), (3, 3), (2, 3)])]),
wkt.loads("POLYGON EMPTY"), # empty, not null
None, # null geometry
]
return gpd.GeoDataFrame(
{
"id": pd.array([1, 2, 3, 4, 5], dtype="int32"),
"name": ["a", "", None, "d", "e"], # empty string vs null
"measure": [1.5, np.nan, 3.25, 0.0, -1.0],
"flag": pd.array([True, False, None, True, False], dtype="boolean"),
"geometry": geoms,
},
crs="EPSG:27700",
)
Row 4 and row 5 are the pair that matters most: an empty geometry and a null geometry are different facts, and a format or driver that conflates them has changed the meaning of every feature that legitimately has no extent.
Step 2 — Normalise before comparing
Write the normalisation as an explicit function so the test reads as a list of tolerated differences:
def normalise(gdf: gpd.GeoDataFrame) -> gpd.GeoDataFrame:
"""Erase the differences the pipeline does not care about."""
out = gdf.copy()
# Column order is a storage detail; put attributes in a stable order.
out = out[sorted(out.columns.drop("geometry")) + ["geometry"]]
# Integer width is a storage detail, not a data property.
for col in out.select_dtypes(include=["int32", "int64", "Int32", "Int64"]):
out[col] = out[col].astype("Int64")
# Row order is not guaranteed by either format.
out = out.sort_values("id").reset_index(drop=True)
return out
Everything this function touches is a difference you have decided to accept. Everything it does not touch is a difference that will fail the test. That inversion — enumerate what is tolerated rather than what is required — is what keeps the suite honest as the formats’ drivers change.
Step 3 — Round-trip through both formats and compare all three
import pytest
from geopandas.testing import assert_geodataframe_equal
@pytest.fixture
def written(tmp_path):
gdf = parity_fixture()
gpkg = tmp_path / "layer.gpkg"
fgb = tmp_path / "layer.fgb"
gdf.to_file(gpkg, layer="parcels", driver="GPKG")
gdf.to_file(fgb, driver="FlatGeobuf")
return gdf, gpd.read_file(gpkg, layer="parcels"), gpd.read_file(fgb)
def test_geopackage_round_trip_preserves_semantics(written):
original, from_gpkg, _ = written
assert_geodataframe_equal(
normalise(original), normalise(from_gpkg),
check_dtype=False, check_crs=True,
)
def test_flatgeobuf_round_trip_preserves_semantics(written):
original, _, from_fgb = written
assert_geodataframe_equal(
normalise(original), normalise(from_fgb),
check_dtype=False, check_crs=True,
)
def test_the_two_formats_agree_with_each_other(written):
_, from_gpkg, from_fgb = written
assert_geodataframe_equal(
normalise(from_gpkg), normalise(from_fgb), check_dtype=False,
)
The third test is the one people skip and the one that pays. Both formats can round-trip against themselves consistently while disagreeing with each other, and if the pipeline emits one format and a consumer reads the other, that disagreement is the bug.
Step 4 — Assert the null-versus-empty distinction directly
assert_geodataframe_equal will catch this, but the failure message is unreadable. A dedicated test names the problem:
def test_empty_and_null_geometry_stay_distinct(written):
original, from_gpkg, from_fgb = written
for label, gdf in (("gpkg", from_gpkg), ("fgb", from_fgb)):
by_id = gdf.set_index("id")
empty = by_id.geometry.loc[4]
null = by_id.geometry.loc[5]
assert empty is not None and empty.is_empty, f"{label}: empty became null"
assert null is None, f"{label}: null became {null!r}"
Choosing which format the pipeline should emit
The round-trip suite is often the first place a team confronts a question it has been avoiding: which format should the pipeline actually produce. The test results make the trade-off concrete rather than theoretical.
GeoPackage earns its place wherever a file must carry several related layers, wherever consumers will query it with SQL, and wherever the output is appended to over time. It is a database, so it supports indexes, views, and metadata tables, and a consumer with SQLite can answer questions without a spatial library at all. The cost is that it is not streamable — a reader must open the file and seek, which rules out serving it directly over HTTP range requests without an intermediate service.
FlatGeobuf earns its place wherever the output is a single layer consumed sequentially or served over the network. Its packed index sits inline, so a client can fetch a byte range covering one region without reading the file, which is what makes it attractive for web delivery. The cost is the single-layer, single-geometry-type constraint and the absence of append.
The decision is rarely either-or in practice: pipelines commonly write GeoPackage as the archival artefact and FlatGeobuf as the delivery artefact from the same source frame. That arrangement is exactly the one the third test above protects, because it is the arrangement in which a producer and a consumer can silently disagree.
Failure modes and edge cases
FlatGeobuf declares one geometry type per file. A mixed layer is written with a promoted type and read back promoted. Decide whether promotion is acceptable, and if it is, normalise Polygon to MultiPolygon on both sides rather than letting the test discover it. If it is not acceptable, assert geom_type.nunique() == 1 on the input before writing.
GDAL version changes the answers. Both drivers have had behaviour changes across releases — GeoPackage’s handling of Int64 and FlatGeobuf’s null support in particular. Pin the stack in the test image, as covered in pinning GDAL and PROJ versions, or the suite will change verdict without any change to your code.
The CRS survives as a definition, not as a string. Comparing crs.to_wkt() across formats fails on formatting differences that mean nothing. Compare crs.to_epsg() where an EPSG code exists and crs.equals(other) where it does not — the CRS drift guide covers the trap in detail.
Column order is not preserved by either format reliably. Sort columns before comparison, as normalise does. A test that fails because a column moved teaches nobody anything.
FlatGeobuf’s spatial index is optional and affects the bytes but not the data. Two files with identical features can have different sizes and different checksums. Never compare files byte-wise; compare the data read from them.
Large fixtures make the failure unreadable. Keep the parity fixture to a handful of rows chosen for their awkwardness. A twenty-thousand-row round trip that fails tells you only that something is different somewhere.
Conclusion
Write down the differences you tolerate as a normalisation function, put the awkward cases — empty geometry, null geometry, empty string, mixed geometry types — into a five-row fixture, and run three comparisons rather than two: each format against the original, and the two formats against each other. That last comparison is where a real mismatch between a producer and a consumer shows up, and it costs one extra assertion.
Related
- Cross-Format Parity Testing — the parent strategy this fits into
- Comparing GeoJSON vs Shapefile Outputs in Tests — the same method for the older format pair
- Detecting CRS Drift Across Format Conversions — the reference-system half of parity
- Testing Coordinate Precision Loss During Conversion — quantifying what a round trip costs
- Pinning GDAL/PROJ Versions in Docker Test Images — keeping the driver behaviour fixed