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.

Three categories of round-trip difference Round-trip differences are grouped into three categories, each with a different correct response. Representation differences, such as an integer column being promoted to a wider width or a datetime losing sub-second precision, are expected consequences of the format's storage model and should be normalised away before the comparison runs. Semantic differences, such as an empty geometry being read back as null, or the distinction between a null and an empty string collapsing, change what the data means and must cause the test to fail. Capability differences, such as one format supporting multiple layers in a single file or accepting a geometry type the other rejects, are structural facts about the formats that should be asserted deliberately rather than discovered through a confusing comparison failure. representation int32 read back as int64 datetime loses sub-second parts column order differs CRS is the same, WKT text differs normalise before comparing expected; failing on these is noise semantic empty geometry becomes null null and empty string collapse coordinate precision truncated Z or M dimension dropped must fail the test the data now means something else capability multi-layer files mixed geometry types per layer spatial index storage appending to an existing file assert explicitly a fact, not a defect

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}"
Null geometry and empty geometry mean different things The two states are compared by what each implies downstream. A null geometry means the attribute row exists but its location is unknown, so a spatial join should exclude the row, an area sum should skip it entirely, and a feature count for a map extent should not include it. An empty geometry means the location is known and is nothing at all, so a spatial join should still exclude the row, but an area sum should count it as exactly zero and a feature count should include it as a real feature. Collapsing the two states changes feature counts and area totals downstream in ways that no error message will ever explain. DOWNSTREAM OPERATION NULL GEOMETRY EMPTY GEOMETRY meaning location unknown location known to be nothing spatial join excluded excluded area sum skipped entirely counted as exactly zero feature count for an extent not a feature a real feature Collapse the two and feature counts and area totals shift — with no error anywhere to explain why. Three comparisons, not two A round-trip test is drawn as an original in-memory frame written to two formats and read back from each. Three comparisons are marked. The first compares the original against the GeoPackage read-back. The second compares the original against the FlatGeobuf read-back. The third compares the two read-backs against each other, and it is the comparison usually omitted; it is also the one that catches a producer and a consumer disagreeing, because each format can round-trip perfectly consistently against itself while still differing from the other. original frame the parity fixture read back from .gpkg normalised read back from .fgb normalised 1 2 3 — usually omitted 1 and 2 prove each format is self-consistent. 3 proves a producer writing one and a consumer reading the other will agree — which 1 and 2 do not establish between them.

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.