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.14andpyogrio>=0.7 - A
.geojsonand a.gpkgsample 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
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.
# 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.
# 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.
# 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.
# 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
bboxto 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
layeron 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:
# 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.
Related
- GeoPackage vs FlatGeobuf for Reporting Pipelines — the next decision once both candidates are binary formats
- Streaming FlatGeobuf Features into Report Context — when even indexed random access is not enough and you must stream
- Automated Static Map Generation from GeoJSON — render the input you chose into a static map for the report
- Parent: Geospatial Data Formats for Reporting Pipelines