Delivering GeoJSON and GeoPackage to BI Tools

This page solves one exact task: exporting scored trade-area polygons from a GeoDataFrame into two spatial deliverables — a WGS84, RFC 7946-compliant GeoJSON for BI tools and a multi-layer, indexed GeoPackage for desktop GIS — so Tableau, Power BI, and QGIS ingest them without a coordinate-reference surprise.

BI and GIS tools are where a scored trade area becomes an interactive map a stakeholder can filter and drill into. These two formats are the machine-readable end of the automated reporting and GIS export stage: GeoJSON is the lingua franca of web and BI ingestion, and GeoPackage is the portable container desktop GIS prefers. The task is narrow but unforgiving — GeoJSON mandates a specific coordinate reference system and polygon winding order, and getting either wrong produces a file that loads but renders inside-out or in the wrong place.

Prerequisites

Before running this task you need:

  • Python packages: geopandas (with the pyogrio or fiona engine) and shapely for the geometry, plus pyproj for CRS handling. Install with pip install geopandas shapely pyproj pyogrio.
  • A scored trade-area frame. A GeoDataFrame with one polygon per candidate site, a suitability_score, attribute columns, and a CRS set. This comes from building weighted site suitability scores and the ranking and shortlisting stage.
  • The parent context. This page implements the write_geojson and write_gpkg writers from Automated Reporting & GIS Export; read that first for the assembler and the coordinate-precision budget.

Configuration and execution parameters

The two writers share a CRS policy but diverge on structure. The table below lists the parameters that determine whether a BI tool ingests the file cleanly.

Parameter Format Value Notes
CRS on export GeoJSON EPSG:4326 (mandatory) RFC 7946 requires WGS84; reproject before writing.
CRS on export GeoPackage any single CRS Stored in the container metadata; keep one CRS per layer.
Winding order GeoJSON right-hand rule Exterior rings counter-clockwise per RFC 7946.
coord_precision GeoJSON 6 decimals ~0.11 m; controls file size vs. fidelity.
Layers GeoPackage sites, trade_areas Multi-layer container; one table per geometry theme.
Spatial index GeoPackage R-tree, on GDAL writes an R-tree so QGIS/ArcGIS query fast.
Geometry type both MultiPolygon Normalize mixed Polygon/MultiPolygon before write.

The one absolute is GeoJSON’s CRS: RFC 7946 defines coordinates as WGS84 longitude/latitude, full stop. A GeoJSON in any other CRS is technically malformed, and BI tools that trust the spec will place its features in the wrong hemisphere. GeoPackage is more forgiving — it records whatever CRS you give it in its metadata — but keeping a single, documented CRS per layer avoids confusing desktop analysts.

Annotated implementation

The writers below reproject explicitly to EPSG:4326 for GeoJSON, enforce the right-hand rule, round coordinates to the configured precision, and write a multi-layer GeoPackage with an R-tree index. Neither mutates the source frame.

python
from __future__ import annotations

from pathlib import Path

import geopandas as gpd
from shapely.geometry.polygon import orient
from shapely.geometry import MultiPolygon
from pyproj import CRS

GEOJSON_CRS = CRS.from_epsg(4326)      # RFC 7946 mandates WGS84


def _normalize(gdf: gpd.GeoDataFrame) -> gpd.GeoDataFrame:
    """Coerce every geometry to a valid, right-hand-oriented MultiPolygon."""
    def fix(geom):
        if geom is None or geom.is_empty:
            return geom
        if geom.geom_type == "Polygon":
            geom = MultiPolygon([orient(geom, sign=1.0)])   # CCW exterior
        elif geom.geom_type == "MultiPolygon":
            geom = MultiPolygon([orient(p, sign=1.0) for p in geom.geoms])
        return geom
    out = gdf.copy()
    out["geometry"] = out.geometry.apply(fix)
    return out


def write_geojson(sites: gpd.GeoDataFrame, cfg) -> Path:
    """Write an RFC 7946 GeoJSON in WGS84 with bounded coordinate precision."""
    assert sites.crs is not None, "scored sites have no CRS; refusing to export"
    # Explicit reprojection to WGS84 — never assume the source is already 4326.
    wgs = sites.to_crs(GEOJSON_CRS)
    assert CRS.from_user_input(wgs.crs) == GEOJSON_CRS
    wgs = _normalize(wgs)
    assert wgs.geometry.is_valid.all(), "invalid geometry before GeoJSON write"

    out_path = cfg.out_dir / f"trade_areas_{cfg.run_id}.geojson"
    out_path.parent.mkdir(parents=True, exist_ok=True)
    # COORDINATE_PRECISION caps decimals -> smaller, deterministic output.
    wgs.to_file(out_path, driver="GeoJSON",
                COORDINATE_PRECISION=cfg.coord_precision, RFC7946="YES")
    return out_path


def write_gpkg(sites: gpd.GeoDataFrame, cfg) -> Path:
    """Write a multi-layer, R-tree-indexed GeoPackage for desktop GIS."""
    assert sites.crs is not None, "scored sites have no CRS; refusing to export"
    sites = _normalize(sites)
    assert sites.geometry.is_valid.all(), "invalid geometry before GeoPackage write"

    out_path = cfg.out_dir / f"site_selection_{cfg.run_id}.gpkg"
    out_path.parent.mkdir(parents=True, exist_ok=True)

    # Point layer (centroids) and polygon layer (trade areas), same container.
    centroids = sites.copy()
    centroids["geometry"] = sites.geometry.centroid
    centroids.to_file(out_path, layer="sites", driver="GPKG",
                      SPATIAL_INDEX="YES")
    sites.to_file(out_path, layer="trade_areas", driver="GPKG",
                  SPATIAL_INDEX="YES")
    return out_path

write_geojson reprojects to WGS84 unconditionally and asserts the result — it never trusts that the source frame is already in EPSG:4326, because a scored frame often arrives in the equal-area CRS used for the ranking math. The RFC7946="YES" driver option and the explicit orient(..., sign=1.0) together guarantee counter-clockwise exterior rings. COORDINATE_PRECISION caps decimals at write time, which is both a size optimization and a determinism guarantee. write_gpkg writes two layers into one SQLite container with SPATIAL_INDEX="YES", so GDAL builds the R-tree that makes QGIS pan and query responsive.

GeoJSON versus GeoPackage export flow A scored trade-area GeoDataFrame is normalized to valid right-hand-oriented MultiPolygons, then branches by target. The BI branch reprojects to EPSG:4326, applies the right-hand rule and coordinate precision, and writes RFC 7946 GeoJSON. The desktop-GIS branch keeps a single CRS and writes a multi-layer, R-tree-indexed GeoPackage. Both paths pass a round-trip validity check. Normalize valid MultiPolygon right-hand rule BI to_crs(4326) precision · RFC 7946 Tableau · Power BI GIS GeoPackage multi-layer · R-tree QGIS · ArcGIS Round-trip read back · validity CRS · count Ship to bundle

Both formats sit alongside the columnar analytical copy of the data — for repeated querying at scale, scored trade areas are also written as partitioned GeoParquet to object storage, covered in configuring AWS S3 for geospatial data lakes.

RFC 7946 winding order: exterior counter-clockwise, holes clockwise Two trade-area polygons with a lake hole. On the left the exterior ring runs counter-clockwise and the hole runs clockwise, which is the right-hand rule RFC 7946 requires, and the renderer cuts the hole out correctly. On the right both rings run the same direction, so a strict renderer fills the hole and leaves the surrounding area empty — the file loads without error and draws inside out. The same rings, two winding orders, two different maps Correct · right-hand rule exterior counter-clockwise · hole clockwise the lake is cut out of the trade area hole Wrong · both rings the same way both rings counter-clockwise a strict renderer fills the lake, empties the rest filled No validator rejects the second file: it is well-formed JSON with valid rings. Only the picture is wrong.

Failure modes and debugging

Symptom Cause Fix
Features render in the wrong place / hemisphere GeoJSON written in a non-WGS84 CRS Reproject to EPSG:4326 before writing; assert the CRS.
Polygons render inside-out or as holes Exterior ring wound clockwise Enforce the right-hand rule with orient(sign=1.0) and RFC7946="YES".
GeoJSON file is enormous Full float64 coordinates, thousands of vertices Cap COORDINATE_PRECISION; simplify geometry in a projected CRS first.
QGIS slow to pan a large GeoPackage No spatial index built Write with SPATIAL_INDEX="YES" so GDAL adds the R-tree.
CRS silently different after export Reprojected for area math, shipped the projected frame Reproject a copy for measurement; export the WGS84 geometry.
Second layer overwrites the first in GPKG Same layer name reused Give each layer a distinct name in the same file.

The winding-order bug is the sneaky one. Pre-RFC 7946 GeoJSON did not constrain ring orientation, so many older files have clockwise exteriors that most viewers tolerate — until a strict RFC 7946 consumer (some Power BI and Mapbox paths) interprets a clockwise exterior as a hole and renders the polygon inside-out. Enforcing counter-clockwise exteriors at write time removes the ambiguity.

Anatomy of a delivered GeoPackage A single gpkg file is a SQLite database holding two feature tables — site points and trade-area polygons — plus the gpkg_contents and gpkg_spatial_ref_sys metadata tables that declare each layer's coordinate reference system, and an R-tree index per geometry column that makes desktop GIS queries fast. A GeoPackage is a SQLite file that declares its own spatial contract shortlist_2026q3.gpkg layer: sites POINT · site_id · rank · suitability_score 240 features · one CRS declared per layer rtree_sites_geom index attached layer: trade_areas MULTIPOLYGON · site_id · band_minutes geometry normalized before write rtree_trade_areas_geom index attached gpkg_contents one row per layer: name, type, bounds, srs_id — what QGIS reads on open gpkg_spatial_ref_sys the full CRS definition travels inside the file — no sidecar projection to lose Because the CRS is declared per layer, mixing projections in one container is legal and silently confusing. This is what GeoJSON cannot carry: several typed layers, indexes, and a self-describing CRS in one file.

Verification

Round-trip every file — read it back and check what a consumer will actually see:

  1. CRS round-trip: the re-read GeoJSON reports EPSG:4326; the GeoPackage reports its intended CRS.
  2. Geometry validity: every re-read geometry passes is_valid.
  3. Feature and layer counts: the GeoJSON feature count matches the source; the GeoPackage exposes both expected layers.
python
import geopandas as gpd
import pyogrio
from pyproj import CRS

def verify_exports(geojson_path, gpkg_path, expected_n: int) -> None:
    gj = gpd.read_file(geojson_path)
    assert CRS.from_user_input(gj.crs) == CRS.from_epsg(4326), "GeoJSON not WGS84"
    assert len(gj) == expected_n, f"GeoJSON {len(gj)} features, expected {expected_n}"
    assert gj.geometry.is_valid.all(), "invalid geometry in GeoJSON round-trip"

    layers = {name for name, _ in pyogrio.list_layers(gpkg_path)}
    assert {"sites", "trade_areas"} <= layers, f"missing GPKG layers: {layers}"
    areas = gpd.read_file(gpkg_path, layer="trade_areas")
    assert len(areas) == expected_n, f"GPKG {len(areas)} rows, expected {expected_n}"
    assert areas.geometry.is_valid.all(), "invalid geometry in GeoPackage round-trip"

Re-reading and re-asserting the CRS is the check that catches the most damaging silent failure — a projected frame written where WGS84 was promised. A file that loads is not a file that is correct; only the round-trip confirms a BI tool will place the features where they belong.

Frequently Asked Questions

Why does my GeoJSON load in a BI tool but appear in the wrong place?

Almost always a coordinate reference system mismatch that raised no error. RFC 7946 defines GeoJSON coordinates as WGS84 longitude and latitude, so a BI tool reads the numbers as degrees whatever they really are. Export a layer still in a projected system such as EPSG:5070 and its coordinates are metres — values in the hundreds of thousands — which a strict reader places far outside the valid degree range or clamps to the edge of the world. Reproject to EPSG:4326 immediately before writing, and assert the CRS inside the writer rather than trusting whatever frame reached it.

Should I simplify geometry before exporting to a BI tool?

Yes, but simplify a copy, and simplify in a projected CRS. A trade-area boundary from a routing engine can carry thousands of vertices no dashboard viewer will ever perceive, and every one of them is bytes in the browser. A tolerance of ten to twenty-five metres usually removes most vertices while leaving the shape visually identical at dashboard zoom levels. Simplifying in degrees is the trap: a tolerance of 0.0001 means a different ground distance at every latitude, and simplification with topology preservation switched off can produce self-intersections the next validity check rejects.

Can one GeoPackage hold both the point sites and the polygon trade areas?

That is exactly what the container is for. Each layer carries its own geometry type, schema and entry in gpkg_contents, so a single delivered file holds candidate points, trade-area polygons and a non-spatial attribute table without any of them interfering. Keep the site identifier identical across layers so joins are unambiguous, and keep every layer in the same CRS unless there is a specific reason not to — mixing coordinate systems inside one container is legal, silently confusing, and eventually produces a map where two layers of the same sites do not line up.

How do I stop a re-export from producing a byte-different file each run?

Pin everything the writer is otherwise free to vary. Sort the frame by a stable key so feature order does not depend on upstream iteration order, round coordinates to the precision budget rather than letting the serializer print full doubles, write columns in an explicit order, and keep the generation timestamp out of the payload — put it in the filename or a sidecar manifest. With those fixed, two exports of the same scored run hash identically, which is what makes the delivery verifiable rather than merely repeatable.

← Back to Automated Reporting & GIS Export