GeoParquet vs GeoPackage for Surveillance Archives
An archival format decision looks like an engineering preference and behaves like a data-governance one: it determines which metadata survives, whether a query five years from now can read a subset without loading everything, and whether the file can be opened at all by whatever tooling exists then. This guide, part of Spatial Data Types & Formats, compares the two serious candidates for surveillance archives.
Problem Context & Constraints
Both formats are open specifications with multiple independent implementations, both preserve a CRS properly, and both handle mixed geometry types. They differ on four axes that matter for an archive.
Type fidelity. GeoPackage is SQLite, so column types are SQLite’s: five storage classes with dynamic typing. GeoParquet is Apache Parquet, with a rich logical type system including proper dates, timestamps with timezone, decimals and nested structures. For surveillance data carrying dates, categorical codes and nullable integers, that difference is substantial — a nullable integer count round-trips exactly in Parquet and becomes a float in many GeoPackage workflows.
Partial reads. Parquet is columnar and supports predicate pushdown and column projection, so reading one year and three columns from a twenty-year archive touches a fraction of the bytes. GeoPackage requires an index and a query, which works, but the file is a single object that must be present in full.
Partitioning. A Parquet dataset is naturally a directory partitioned by year or county, so a new period is a new file rather than a rewrite. A GeoPackage grows, and a growing SQLite file is a concurrency and backup liability.
Editability and tooling breadth. GeoPackage is a working format: desktop GIS opens it, edits it and writes it back. GeoParquet is read-mostly, and while support is now broad it is younger.
Prerequisites
python3.11,geopandas1.0.1,pyarrow16.1.0,shapely2.0.6- A declared archival CRS, chosen per Choosing an Equal-Area Projection for Rate Mapping where areas matter
- A schema for the archive — column names, types and nullability — written down before the first write
The operational difference between the two formats shows up most clearly in the question an archive is actually asked:
Step-by-Step Solution
# Write a partitioned GeoParquet surveillance archive with a checked schema.
# Pinned: geopandas==1.0.1, pyarrow==16.1.0, pandas==2.2.2
import logging
import pandas as pd
import geopandas as gpd
import pyarrow as pa
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("format.archive")
# The schema is the contract. Declaring it means a column whose type drifts
# between years fails on write rather than being discovered on read in 2031.
ARCHIVE_SCHEMA = {
"geoid": "string",
"period": "date32[day][pyarrow]",
"observed": "int32",
"expected": "float64",
"suppressed": "bool",
"method_version": "string",
}
def check_schema(gdf: gpd.GeoDataFrame) -> None:
missing = set(ARCHIVE_SCHEMA) - set(gdf.columns)
if missing:
raise ValueError(f"archive schema requires {sorted(missing)}")
for col, want in ARCHIVE_SCHEMA.items():
got = str(gdf[col].dtype)
if got != want:
raise TypeError(f"column {col!r} is {got}, archive requires {want}. "
"Cast explicitly rather than letting the writer guess.")
log.info("schema check passed on %d rows", len(gdf))
def write_partition(gdf: gpd.GeoDataFrame, root: str, year: int,
archive_crs: str = "EPSG:5070") -> str:
"""One file per year. A new period never rewrites an existing one."""
check_schema(gdf)
if gdf.crs is None:
raise ValueError("refusing to archive geometry with no CRS")
out = gdf.to_crs(archive_crs)
path = f"{root}/year={year}/part-0.parquet"
out.to_parquet(path, compression="zstd", write_covering_bbox=True, index=False)
log.info("wrote %s (%d rows, CRS %s)", path, len(out), archive_crs)
return path
def read_slice(root: str, years: list[int], columns: list[str]) -> gpd.GeoDataFrame:
"""Read only the years and columns needed.
This is the operational payoff: a twenty-year archive answers a three-year,
four-column question without decompressing the other seventeen years."""
cols = list(dict.fromkeys(columns + ["geometry"]))
gdf = gpd.read_parquet(root, columns=cols,
filters=[("year", "in", years)])
log.info("read %d rows from %d year(s), %d column(s)", len(gdf), len(years), len(cols))
return gdf
write_covering_bbox=True is worth calling out: it stores a bounding-box column that lets a reader do spatial predicate pushdown, so a county-level question does not read the state. Without it, GeoParquet’s spatial querying is no better than a full scan.
Validation & Edge Cases
1. Round-trip the schema before committing to a format. Write a representative frame, read it back, and compare dtypes exactly. This is where GeoPackage’s dynamic typing shows up:
INFO schema check passed on 18,204 rows
INFO wrote archive/year=2026/part-0.parquet (18204 rows, CRS EPSG:5070)
INFO read 54,612 rows from 3 year(s), 5 column(s)
WARNING gpkg round trip: 'observed' int32 -> int64, 'suppressed' bool -> int64, 'period' date32 -> object
2. Keep a GeoPackage export for distribution, not for archive. The two roles are different. Archive in GeoParquet and generate a GeoPackage on demand for partners whose tooling needs it, recording which archive version each export came from.
3. Do not partition too finely. A partition per county per month produces tens of thousands of tiny files and reads slower than one file per year. Year, or year and state, is usually right.
4. Store the CRS in the file, not in a README. Both formats carry it properly; the failure is a pipeline that writes without setting it, which is why the code above refuses geometry with no CRS.
5. Plan for the format outliving its library. Both are open specifications with independent implementations, which is the property that matters. Verify that at least two independent readers can open the archive, and record the specification version in the archive metadata.
6. Decide the geometry encoding explicitly. GeoParquet supports well-known binary and, in newer versions, native nested coordinate encodings. The choice affects which readers can open the file and how efficiently a bounding-box filter runs, and it is easy to leave to a library default that changes between versions. Pin the encoding in the write call and record it with the schema version.
7. Keep an integrity manifest. An archive spanning a decade will be copied between storage systems several times, and silent corruption in one partition is invisible until somebody reads it. A manifest of per-file checksums, verified on a schedule, turns that from a discovery into an alert.
8. Test the read path, not only the write path. An archive is written once and read for years, so the check that matters is that a fresh environment with only the pinned dependencies can open a partition, resolve its CRS and reproduce a known summary statistic. Running that check quarterly is inexpensive and is the only thing that actually establishes the archive is still usable.
9. Write the archive from a single code path. Where several pipelines contribute to one archive, each will drift toward its own conventions for nullability, string encoding and column order, and the schema check will start failing for reasons nobody wants to debug. A single writer module that every pipeline calls is a small amount of shared code that keeps the archive coherent for its whole life.
10. Decide the retention rule before the archive is large. An archive with no stated retention policy grows until somebody deletes something under pressure, which is the worst moment to be making that decision. Write the rule down at the same time as the schema, and make it specific about which partitions are permanent and which may be aggregated after a stated period.
The layout itself carries much of the governance, so it is worth drawing:
Compliance Notes
- Version the archive, never overwrite a period. A corrected year is a new file with a new version, and the superseded one stays retrievable, since published figures were computed from it.
- Record the schema and its version in the archive metadata, so a reader in five years knows what the columns meant.
- Keep the archival CRS fixed for the life of the archive. Changing it mid-series makes areas incomparable across years even though every file is individually correct.
- Treat the archive as restricted where it holds unsuppressed counts, and generate suppressed derivatives for distribution rather than suppressing in place.
Related Topics
- Spatial Data Types & Formats in Production Epidemiology Pipelines — the parent guide, including the format-capability matrix.
- Converting Shapefiles to GeoJSON for Epi Pipelines — the interchange path, and what it costs.
- Choosing an Equal-Area Projection for Rate Mapping — fixing the archival CRS before the first write.