Choosing GeoJSON vs GeoPackage for Report Inputs

GeoJSON and GeoPackage are the two formats a report pipeline meets most often, and the choice between them is rarely about geometry — it is about size, editability, and how the data reached you. GeoJSON is text: a single RFC 7946 document in WGS84, human-readable, diff-able, and parsed in full every time it is opened. GeoPackage is a binary SQLite database: indexed, multi-layer, any CRS, and queryable without reading the whole file. This guide is the entry-level decision inside Geospatial Data Formats for Reporting Pipelines; once both of your candidates are binary, move on to GeoPackage vs FlatGeobuf for Reporting Pipelines.

Prerequisites

  • Python 3.10+ with geopandas>=0.14 and pyogrio>=0.7
  • A .geojson and a .gpkg sample to test both read paths
  • A decision on your report CRS, since GeoJSON forces WGS84 while GeoPackage does not
  • The ingestion context from the parent Geospatial Data Formats for Reporting Pipelines guide

The Core Trade-off

GeoJSON vs GeoPackage Read Model GeoJSON is a single WGS84 text document that must be parsed in full. GeoPackage is an indexed SQLite database with multiple layers that can be queried without reading the whole file. GeoJSON single text document RFC 7946 · WGS84 only parse whole file all features in memory no index · one layer GeoPackage SQLite database any CRS · many layers query by index only matching features R-tree · per-layer read Interchange & readability vs scale & queryability

Comparison Table

Dimension GeoJSON GeoPackage
Encoding UTF-8 text (JSON) Binary SQLite
Specification RFC 7946 OGC GeoPackage
CRS WGS84 (EPSG:4326) only Any CRS, stored per layer
Layers per file One Many, plus attribute-only tables
Spatial index None R-tree
Read cost Whole-file parse every open Indexed, query only what you need
Human-readable Yes — diff-able in Git No — inspect with tooling
Editing Rewrite the whole file Transactional in-place edits
Typical origin Web APIs, ogr2ogr exports, hand edits Desktop GIS (QGIS), curated stores
Sweet spot Small interchange payloads (< a few MB) Large or multi-layer report inputs

Step 1: Classify the Input by Size and Lifecycle

The decision is mechanical once you answer two questions. Is the file small and arriving from an external system as interchange? GeoJSON is fine, and its readability is a genuine asset. Is it large, edited in desktop GIS, or carrying several layers? GeoPackage’s index and multi-layer model pay for themselves immediately.

Python
# classify.py
from __future__ import annotations

from pathlib import Path

def suggest_format(path: str, size_threshold_mb: float = 5.0) -> str:
    """Heuristic: flag GeoJSON inputs that have outgrown text."""
    p = Path(path)
    size_mb = p.stat().st_size / 1_048_576
    if p.suffix.lower() in {".geojson", ".json"} and size_mb > size_threshold_mb:
        return "convert-to-gpkg"  # whole-file parse is now the bottleneck
    return "keep"

The 5 MB line is a rule of thumb, not a law: a GeoJSON parsed once per nightly run tolerates far more than one parsed per web request. Weigh it against how often the report reads the file.

Step 2: Read GeoJSON Safely for Small Interchange Inputs

When GeoJSON is the right call, read it with pyogrio and immediately confirm the CRS is what RFC 7946 promises. GeoJSON is defined as WGS84, but exporters occasionally embed a non-standard crs member, and trusting the spec blindly is how coordinates end up in the wrong place.

Python
# read_geojson.py
from __future__ import annotations

import geopandas as gpd

def read_geojson(path: str, report_epsg: int) -> gpd.GeoDataFrame:
    gdf = gpd.read_file(path, engine="pyogrio")
    # RFC 7946 mandates WGS84; assert rather than assume
    if gdf.crs is not None and gdf.crs.to_epsg() not in (4326, None):
        raise ValueError(f"Non-standard GeoJSON CRS: {gdf.crs.to_epsg()}")
    return gdf.to_crs(epsg=report_epsg)

Because GeoJSON has no index, a bbox argument here only trims the result — the parser still reads every byte. That is acceptable for a small payload and unacceptable for a large one, which is exactly the crossover Step 1 detects.

Step 3: Read GeoPackage for Large or Multi-Layer Inputs

GeoPackage rewards the report pipeline with two things GeoJSON cannot offer: a named layer read and an index-backed extent filter. Both are single arguments to the same read_file call.

Python
# read_gpkg.py
from __future__ import annotations

import geopandas as gpd
import pyogrio

def read_gpkg_extent(
    path: str, layer: str, bbox: tuple[float, float, float, float], report_epsg: int
) -> gpd.GeoDataFrame:
    available = [name for name, _ in pyogrio.list_layers(path)]
    if layer not in available:
        raise KeyError(f"{layer!r} not found; layers are {available}")
    gdf = gpd.read_file(path, layer=layer, bbox=bbox, engine="pyogrio")
    return gdf.to_crs(epsg=report_epsg)

Naming the layer is mandatory on a multi-layer container — omit it and pyogrio reads the first table silently. The bbox here is a real optimisation because the R-tree lets GDAL skip non-intersecting features, unlike the GeoJSON case in Step 2. The rendered result flows straight into Loop Mapping for Dynamic Attribute Tables, and an empty extent is handled by Conditional Rendering for Missing Spatial Data.

Step 4: Convert Between the Two When the Lifecycle Changes

Formats are not a permanent commitment. When a GeoJSON interchange payload becomes a recurring report input, convert it once to GeoPackage and read the binary from then on.

Python
# convert.py
from __future__ import annotations

import geopandas as gpd

def geojson_to_gpkg(src: str, dst: str, layer: str) -> None:
    """One-time conversion so later reads use the indexed binary."""
    gdf = gpd.read_file(src, engine="pyogrio")
    gdf.to_file(dst, layer=layer, driver="GPKG", engine="pyogrio")

The reverse conversion — GeoPackage to GeoJSON — is equally valid when a downstream consumer needs a diff-able, WGS84 interchange file; just remember it collapses to a single layer in WGS84 and drops the index.

Key Parameters / Configuration Reference

Parameter Type Default Effect
engine str "pyogrio" Vectorised read path for both formats
layer str first layer Required for multi-layer GeoPackage; unused for GeoJSON
bbox tuple[float, ...] Real index filter on GeoPackage; result-trim only on GeoJSON
driver str inferred Force "GPKG" or "GeoJSON" on write
report_epsg int CRS to reproject into; GeoJSON always starts at 4326

Common Pitfalls

  • Trusting the GeoJSON CRS blindly. RFC 7946 mandates WGS84, but non-conforming exporters embed other CRS members. Assert the EPSG code on read rather than assuming.
  • Expecting bbox to speed up GeoJSON. With no index, the parser reads the whole file regardless; the filter only shrinks the returned frame. Convert to GeoPackage if the read cost matters.
  • Omitting layer on a GeoPackage. The first table is read silently, producing a plausible-but-wrong report. Always list layers and name one.
  • Diffing a GeoPackage in Git. It is a binary blob — a one-row edit shows as a full-file change. Keep GeoJSON for anything that benefits from readable version history.

Verification

Confirm the read produced the CRS and feature set you expect before rendering:

Python
# verify_input.py
from read_geojson import read_geojson
from read_gpkg import read_gpkg_extent

gj = read_geojson("boundary.geojson", report_epsg=27700)
assert gj.crs.to_epsg() == 27700, "GeoJSON must reproject to report CRS"

gp = read_gpkg_extent("master.gpkg", "parcels",
                      (530000, 180000, 545000, 195000), report_epsg=27700)
assert not gp.empty, "extent should intersect the parcels layer"
print("Input reads verified")

In CI, add a guard that fails the build if a GeoJSON input exceeds the size threshold, nudging the team to convert it to GeoPackage before read cost degrades the nightly run.