How to Package GIS Deliverables into Zipped Bundles with Python

On this page

Problem statement

The analysis is finished. Now it has to leave your machine, and that is its own job:

data/out/
  parcels_final.gpkg
  parcels_final_v2.gpkg
  parcels_FINAL_use_this.gpkg
  roads.shp                 (missing roads.prj)
  notes.txt                 (empty)

The client gets a zip, opens it in a different GIS, and asks what the CRS is, when it was produced, what the class codes mean, and why one layer is empty. Two weeks later nobody can say which version they were sent.

A deliverable is not the same thing as an output file. It is a bundle: the data, in an agreed format and CRS, plus enough metadata for someone else to use it without asking you, plus a checksum so they can tell it arrived intact β€” and it should be produced by a script, so the next one is identical.

Common failure modes:

  • shapefile sidecars missing from the zip, so the layer will not open
  • no CRS information, or a .prj that disagrees with the documentation
  • no version, date, or provenance β€” two bundles that cannot be told apart
  • absolute paths or __MACOSX junk inside the archive
  • no checksum, so a truncated download looks like corrupt data
  • a README written by hand, therefore out of date

Quick answer

Build the bundle in a staging folder, then archive it:

  1. export each layer to the agreed format and CRS
  2. write a manifest (layer list, counts, CRS, extent) and a README
  3. add a checksum file for every payload file
  4. zip the staging folder with a versioned name
  5. verify by unpacking to a temporary folder and reading it back
from pathlib import Path
from datetime import datetime, timezone
import hashlib, json, shutil
import geopandas as gpd

stamp = datetime.now(timezone.utc).strftime("%Y%m%d")
stage = Path(f"build/parcels_delivery_{stamp}")
(stage / "data").mkdir(parents=True, exist_ok=True)

gdf = gpd.read_file("data/clean/parcels.gpkg").to_crs("EPSG:27700")
gdf.to_file(stage / "data" / "parcels.gpkg", layer="parcels", driver="GPKG")

manifest = {
    "name": "parcels_delivery", "version": stamp,
    "created_utc": datetime.now(timezone.utc).isoformat(timespec="seconds"),
    "crs": "EPSG:27700",
    "layers": [{"file": "data/parcels.gpkg", "layer": "parcels",
                "features": len(gdf), "geometry": str(gdf.geom_type.mode()[0])}],
}
(stage / "manifest.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8")

lines = []
for f in sorted(p for p in stage.rglob("*") if p.is_file() and p.name != "SHA256SUMS"):
    digest = hashlib.sha256(f.read_bytes()).hexdigest()
    lines.append(f"{digest}  {f.relative_to(stage)}")
(stage / "SHA256SUMS").write_text("\n".join(lines) + "\n", encoding="utf-8")

archive = shutil.make_archive(str(stage), "zip", root_dir=stage.parent, base_dir=stage.name)
print(f"{Path(archive).name}  {Path(archive).stat().st_size/1e6:.1f} MB")
parcels_delivery_20261010.zip  6.6 MB

Staging first is what makes the bundle reproducible: the archive is a faithful copy of a directory you can inspect, re-run, and diff.

What goes in a bundle

The four layers of a GIS deliverable bundle. what goes in the zipdata/agreed format + CRSmanifest.jsoncounts, CRS, extentREADME.mdgenerated, not typedLICENCE / attributionterms of useSHA256SUMSsha256sum -cOnly the first is what they asked for; all five are what they need spatialworkflow.io
Four layers β€” only the first is the thing people ask for, and all four are what they need.

Contract, export, metadata, checksums, archive

Building a deliverable from export through checksums to verification. stage it, then archive it1export to the agreed formatand the agreed CRS2generate manifest + READMEfrom the data3write SHA256SUMSevery payload file4zip the staging foldersidecars included5unpack and read it backthe real test6publish and log what was sentan append-only ledgerArchiving a folder cannot forget a file that is in the folder spatialworkflow.io
Six stages β€” the verify step is the one that catches the missing sidecar.

Decide the contract first

Write down what the bundle promises, and encode it in the script rather than in an email.

DELIVERY = {
    "name": "parcels_delivery",
    "crs": "EPSG:27700",
    "formats": ["gpkg", "shp"],          # what the recipient can actually open
    "layers": {
        "parcels": {"source": "data/clean/parcels.gpkg", "min_features": 1000},
        "roads":   {"source": "data/clean/roads.gpkg",   "min_features": 100},
    },
    "attribution": ("This information is subject to Crown copyright and database rights 2026 and is "
                    "reproduced with the permission of HM Land Registry. Roads Β© OpenStreetMap contributors."),
    "licence": "OGL v3.0 (parcels); ODbL 1.0 (roads)",
}

Minimum feature counts turn "the layer is empty" from a discovery at the client's end into a build failure at yours.

Export to the agreed format and CRS

from pathlib import Path
import geopandas as gpd

def export_layer(gdf, stage: Path, name: str, formats, crs) -> list[dict]:
    gdf = gdf.to_crs(crs)
    written = []
    for fmt in formats:
        if fmt == "gpkg":
            dest = stage / "data" / f"{name}.gpkg"
            dest.parent.mkdir(parents=True, exist_ok=True)
            gdf.to_file(dest, layer=name, driver="GPKG")
        elif fmt == "shp":
            dest = stage / "data" / "shapefile" / f"{name}.shp"
            dest.parent.mkdir(parents=True, exist_ok=True)
            export = gdf.copy()
            export.columns = [c[:10] for c in export.columns]      # dBase field-name limit
            export.to_file(dest, driver="ESRI Shapefile", encoding="utf-8")
        elif fmt == "geojson":
            dest = stage / "data" / f"{name}.geojson"
            gdf.to_crs(4326).to_file(dest, driver="GeoJSON", COORDINATE_PRECISION=6)
        else:
            raise ValueError(f"unsupported format: {fmt}")
        written.append({"format": fmt, "path": str(dest.relative_to(stage)),
                        "features": len(gdf)})
    return written

stage = Path("build/format_test")
parcels = gpd.read_file("data/clean/parcels.gpkg")
for record in export_layer(parcels, stage, "parcels", ["gpkg", "shp", "geojson"], "EPSG:27700"):
    print(record, "| fields:", [f for f in pyogrio.read_info(stage / record["path"])["fields"]])
{'format': 'gpkg', 'path': 'data/parcels.gpkg', 'features': 21253} | fields: ['gml_id', 'INSPIREID', 'LABEL', 'NATIONALCADASTRALREFERENCE', 'VALIDFROM', 'BEGINLIFESPANVERSION', 'region']
{'format': 'shp', 'path': 'data/shapefile/parcels.shp', 'features': 21253} | fields: ['gml_id', 'INSPIREID', 'LABEL', 'NATIONALCA', 'VALIDFROM', 'BEGINLIFES', 'region']
{'format': 'geojson', 'path': 'data/parcels.geojson', 'features': 21253} | fields: ['gml_id', 'INSPIREID', 'LABEL', 'NATIONALCADASTRALREFERENCE', 'VALIDFROM', 'BEGINLIFESPANVERSION', 'region']

When shapefile is on the list, truncate the field names deliberately so you choose the short names rather than letting the driver choose them β€” and check the truncation did not create duplicates. Look at the types as well as the names: the parcels' two timestamp columns arrive in the shapefile as text, because a dBase file has a date type but no date-and-time.

Collect every part of a shapefile

The most common broken deliverable is a zip containing roads.shp and nothing else.

SHP_PARTS = (".shp", ".shx", ".dbf", ".prj", ".cpg", ".qix", ".sbn", ".sbx")

def shapefile_files(shp: Path) -> list[Path]:
    return [shp.with_suffix(ext) for ext in SHP_PARTS if shp.with_suffix(ext).exists()]

def check_shapefile_complete(shp: Path) -> list[str]:
    missing = [ext for ext in (".shx", ".dbf", ".prj") if not shp.with_suffix(ext).exists()]
    return [f"{shp.name}: missing {', '.join(missing)}"] if missing else []

shp = stage / "data" / "shapefile" / "parcels.shp"
print([p.name for p in shapefile_files(shp)], check_shapefile_complete(shp) or "complete")
['parcels.shp', 'parcels.shx', 'parcels.dbf', 'parcels.prj', 'parcels.cpg'] complete

Because the bundle is built by copying a staging folder wholesale, sidecars come along automatically β€” which is one more reason to stage rather than zip a hand-picked file list.

Write the metadata by generating it

import json
from datetime import datetime, timezone
from pathlib import Path
import subprocess

def build_manifest(stage: Path, delivery: dict, layers: list[dict]) -> dict:
    def git_rev():
        try:
            return subprocess.run(["git", "rev-parse", "--short", "HEAD"],
                                  capture_output=True, text=True, check=True).stdout.strip()
        except Exception:
            return None

    return {
        "name": delivery["name"],
        "version": datetime.now(timezone.utc).strftime("%Y%m%d"),
        "created_utc": datetime.now(timezone.utc).isoformat(timespec="seconds"),
        "crs": delivery["crs"],
        "licence": delivery.get("licence"),
        "attribution": delivery.get("attribution"),
        "produced_by": {"tool": "package_delivery.py", "git_rev": git_rev()},
        "layers": layers,
        "files": sorted(str(p.relative_to(stage)) for p in stage.rglob("*") if p.is_file()),
    }

Generate the README from the same manifest so the two can never disagree:

def build_readme(manifest: dict) -> str:
    lines = [
        f"# {manifest['name']} ({manifest['version']})", "",
        f"Created: {manifest['created_utc']}",
        f"CRS: {manifest['crs']}",
        f"Licence: {manifest['licence']}", "",
        "## Layers", "",
        "| layer | file | features | geometry |",
        "|---|---|---|---|",
    ]
    for lyr in manifest["layers"]:
        lines.append(f"| {lyr['layer']} | {lyr['path']} | {lyr['features']:,} | {lyr['geometry']} |")
    fence = "`" * 3                      # build it, so the snippet stays copy-pasteable
    lines += ["", "## Verifying this bundle", "",
              fence + "bash", "sha256sum -c SHA256SUMS", fence, "",
              "## Attribution", "", manifest.get("attribution") or "β€”", ""]
    return "\n".join(lines)

Checksum everything

import hashlib
from pathlib import Path

def write_checksums(stage: Path, filename="SHA256SUMS") -> Path:
    dest = stage / filename
    lines = []
    for f in sorted(p for p in stage.rglob("*") if p.is_file() and p.name != filename):
        h = hashlib.sha256()
        with open(f, "rb") as fh:
            for block in iter(lambda: fh.read(1 << 20), b""):
                h.update(block)
        lines.append(f"{h.hexdigest()}  {f.relative_to(stage).as_posix()}")
    dest.write_text("\n".join(lines) + "\n", encoding="utf-8")
    return dest

The format matches sha256sum -c, so the recipient verifies with a command they already have.

Archive deterministically

shutil.make_archive is fine for most cases; when byte-identical rebuilds matter, control the order and timestamps.

import zipfile
from pathlib import Path

def make_zip(stage: Path, dest: Path, deterministic: bool = True) -> Path:
    dest.parent.mkdir(parents=True, exist_ok=True)
    files = sorted(p for p in stage.rglob("*") if p.is_file())
    with zipfile.ZipFile(dest, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=6) as zf:
        for f in files:
            arcname = Path(stage.name) / f.relative_to(stage)
            if deterministic:
                info = zipfile.ZipInfo(str(arcname.as_posix()), date_time=(1980, 1, 1, 0, 0, 0))
                info.compress_type = zipfile.ZIP_DEFLATED
                info.external_attr = 0o644 << 16
                zf.writestr(info, f.read_bytes())
            else:
                zf.write(f, arcname)
    return dest
import pyogrio

def zip_digest(stage):
    return hashlib.sha256(make_zip(stage, Path("build/test.zip")).read_bytes()).hexdigest()[:12]

def stage_once(pin_date):
    stage = Path("build/determinism")
    shutil.rmtree(stage, ignore_errors=True)
    (stage / "data").mkdir(parents=True)
    pyogrio.set_gdal_config_options({"OGR_CURRENT_DATE": "2026-09-01T00:00:00Z" if pin_date else None})
    parcels.to_file(stage / "data" / "parcels.gpkg", layer="parcels", driver="GPKG")
    pyogrio.set_gdal_config_options({"OGR_CURRENT_DATE": None})
    time.sleep(1.1)
    return stage

for pin_date in (False, True):
    digests = {zip_digest(stage_once(pin_date)) for _ in range(2)}
    print(f"OGR_CURRENT_DATE pinned: {pin_date!s:5}  two builds -> {len(digests)} distinct zip(s)")
OGR_CURRENT_DATE pinned: False  two builds -> 2 distinct zip(s)
OGR_CURRENT_DATE pinned: True   two builds -> 1 distinct zip(s)

Fixing the zip's timestamps means two builds of the same files produce the same bytes β€” but a GeoPackage is not the same file twice: GDAL stamps each layer's last_change with the time of writing. Pin OGR_CURRENT_DATE to the delivery date as well, and a checksum tells you whether the data changed rather than when the bundle was made.

Verify the bundle by opening it

The final step is the one that catches everything else.

import tempfile, zipfile, hashlib
from pathlib import Path
import geopandas as gpd

def verify_bundle(zip_path: Path) -> dict:
    results = {"zip": str(zip_path), "problems": []}
    with tempfile.TemporaryDirectory() as tmp:
        with zipfile.ZipFile(zip_path) as zf:
            bad = zf.testzip()
            if bad:
                results["problems"].append(f"corrupt entry: {bad}")
            zf.extractall(tmp)

        root = next(Path(tmp).iterdir())
        sums = root / "SHA256SUMS"
        if not sums.exists():
            results["problems"].append("no SHA256SUMS")
        else:
            for line in sums.read_text().splitlines():
                digest, name = line.split("  ", 1)
                f = root / name
                if not f.exists():
                    results["problems"].append(f"listed but missing: {name}")
                elif hashlib.sha256(f.read_bytes()).hexdigest() != digest:
                    results["problems"].append(f"checksum mismatch: {name}")

        for data_file in sorted((root / "data").rglob("*")):
            if data_file.suffix.lower() in {".gpkg", ".shp", ".geojson"}:
                try:
                    g = gpd.read_file(data_file)
                    if g.empty:
                        results["problems"].append(f"empty layer: {data_file.name}")
                    if g.crs is None:
                        results["problems"].append(f"no CRS: {data_file.name}")
                except Exception as exc:
                    results["problems"].append(f"unreadable {data_file.name}: {exc}")

    results["ok"] = not results["problems"]
    return results

broken = Path("build/roads_only.zip")                 # the classic: the .shp and nothing else
roads_stage = Path("build/roads_stage/data")
roads_stage.mkdir(parents=True, exist_ok=True)
gpd.read_file("data/clean/roads.gpkg").to_file(roads_stage / "roads.shp")
with zipfile.ZipFile(broken, "w") as zf:
    zf.write(roads_stage / "roads.shp", "roads_delivery/data/roads.shp")
import re
print([re.sub(r"\S*/roads_delivery/", "", problem.split(" or ")[0]) for problem in verify_bundle(broken)["problems"]])
['no SHA256SUMS', 'unreadable roads.shp: Unable to open data/roads.shx']

Reading the data back out of the archive is the only check that proves the bundle works on a machine that is not yours.

Bundling Rutland's parcels and roads

The data used on this page

Two real layers for a delivery: HM Land Registry's INSPIRE parcels for Rutland, tagged with the ONS ward each falls in (the region Example 2 splits on), and OpenStreetMap roads for Rutland from Geofabrik's dated January 2026 snapshot β€” both in British National Grid, in data/clean/.

import hashlib
import io
import json
import shutil
import time
import zipfile
from pathlib import Path

import geopandas as gpd
import pyogrio
import requests

pyogrio.set_gdal_config_options({"GML_DOWNLOAD_SCHEMA": "NO"})
clean = Path("data/clean")
clean.mkdir(parents=True, exist_ok=True)
if not (clean / "parcels.gpkg").exists():
    reply = requests.Session().get("https://use-land-property-data.service.gov.uk/datasets/inspire/download/"
                                   "Rutland.zip", timeout=600)
    reply.raise_for_status()
    zipfile.ZipFile(io.BytesIO(reply.content)).extract("Land_Registry_Cadastral_Parcels.gml", "downloads")
    parcels = gpd.read_file("downloads/Land_Registry_Cadastral_Parcels.gml")
    wards = gpd.read_file(io.BytesIO(requests.get(
        "https://services1.arcgis.com/ESMARspQHYMw9BZ9/arcgis/rest/services/"
        "Wards_December_2024_Boundaries_UK_BGC/FeatureServer/0/query",
        params={"where": "LAD24NM='Rutland'", "outFields": "WD24NM", "outSR": 27700, "f": "geojson"},
        timeout=120).content))
    centres = parcels.assign(geometry=parcels.representative_point())
    parcels["region"] = gpd.sjoin(centres, wards, predicate="within", how="left")["WD24NM"]
    parcels.to_file(clean / "parcels.gpkg")
if not (clean / "roads.gpkg").exists():
    reply = requests.get("https://download.geofabrik.de/europe/united-kingdom/england/"
                         "rutland-260101-free.shp.zip", timeout=300)
    reply.raise_for_status()
    zipfile.ZipFile(io.BytesIO(reply.content)).extractall("downloads/osm")
    gpd.read_file("downloads/osm/gis_osm_roads_free_1.shp").to_crs(27700).to_file(clean / "roads.gpkg")

parcels = gpd.read_file(clean / "parcels.gpkg")
print(f"{len(parcels):,} parcels in {parcels['region'].nunique()} wards; "
      f"{pyogrio.read_info(clean / 'roads.gpkg')['features']:,} roads")
21,253 parcels in 15 wards; 7,292 roads

Example 1: the complete packaging script

#!/usr/bin/env python3
"""package_delivery.py β€” build a verified, documented GIS deliverable."""
from datetime import datetime, timezone
from pathlib import Path
import argparse, hashlib, json, shutil, sys
import geopandas as gpd

def build(delivery: dict, out_dir: Path, dry_run: bool = False) -> Path | None:
    version = datetime.now(timezone.utc).strftime("%Y%m%d")
    stage = out_dir / f"{delivery['name']}_{version}"
    if stage.exists():
        shutil.rmtree(stage)
    (stage / "data").mkdir(parents=True)

    layers = []
    for name, spec in delivery["layers"].items():
        gdf = gpd.read_file(spec["source"]).to_crs(delivery["crs"])
        if len(gdf) < spec.get("min_features", 0):
            raise ValueError(f"{name}: {len(gdf)} features, expected at least {spec['min_features']}")
        dest = stage / "data" / f"{name}.gpkg"
        gdf.to_file(dest, layer=name, driver="GPKG")
        bounds = [round(v, 3) for v in gdf.total_bounds]
        layers.append({"layer": name, "path": str(dest.relative_to(stage)),
                       "features": len(gdf), "geometry": str(gdf.geom_type.mode()[0]),
                       "bounds": bounds, "fields": [c for c in gdf.columns if c != "geometry"]})
        print(f"  {name}: {len(gdf):,} features β†’ {dest.relative_to(stage)}")

    manifest = {"name": delivery["name"], "version": version,
                "created_utc": datetime.now(timezone.utc).isoformat(timespec="seconds"),
                "crs": delivery["crs"], "licence": delivery.get("licence"),
                "attribution": delivery.get("attribution"), "layers": layers}
    (stage / "manifest.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8")
    (stage / "README.md").write_text(build_readme(manifest), encoding="utf-8")

    sums = []
    for f in sorted(p for p in stage.rglob("*") if p.is_file()):
        sums.append(f"{hashlib.sha256(f.read_bytes()).hexdigest()}  {f.relative_to(stage).as_posix()}")
    (stage / "SHA256SUMS").write_text("\n".join(sums) + "\n", encoding="utf-8")

    if dry_run:
        print(f"dry run: staged at {stage}, not archived")
        return None

    archive = Path(shutil.make_archive(str(stage), "zip",
                                       root_dir=stage.parent, base_dir=stage.name))
    print(f"\n{archive.name}  ({archive.stat().st_size/1e6:.1f} MB)")
    return archive

def main(argv=None):
    ap = argparse.ArgumentParser()
    ap.add_argument("-o", "--out", type=Path, default=Path("build"))
    ap.add_argument("-n", "--dry-run", action="store_true")
    args = ap.parse_args(argv)
    try:
        archive = build(DELIVERY, args.out, args.dry_run)
    except Exception as exc:
        print(f"packaging failed: {exc}", file=sys.stderr)
        return 1
    if archive:
        report = verify_bundle(archive)
        print("verification:", "ok" if report["ok"] else report["problems"])
        return 0 if report["ok"] else 1
    return 0

if __name__ == "__main__":
    raise SystemExit(main())
print("exit code:", main(["-o", "build/delivery"]))
readme = Path(sorted(Path("build/delivery").glob("*/README.md"))[0]).read_text()
print("\n".join(readme.splitlines()[:12]))
  parcels: 21,253 features β†’ data/parcels.gpkg
  roads: 7,292 features β†’ data/roads.gpkg

parcels_delivery_20261010.zip  (7.7 MB)
verification: ok
exit code: 0
# parcels_delivery (20261010)

Created: 2026-10-10T23:47:40+00:00
CRS: EPSG:27700
Licence: OGL v3.0 (parcels); ODbL 1.0 (roads)

## Layers

| layer | file | features | geometry |
|---|---|---|---|
| parcels | data/parcels.gpkg | 21,253 | Polygon |
| roads | data/roads.gpkg | 7,292 | LineString |

Example 2: one bundle per region

from pathlib import Path
import geopandas as gpd

gdf = gpd.read_file("data/clean/parcels.gpkg")

for region, part in list(gdf.groupby("region"))[:5]:
    stage = Path(f"build/parcels_{region.lower().replace(' ', '_')}")
    (stage / "data").mkdir(parents=True, exist_ok=True)
    part.to_file(stage / "data" / "parcels.gpkg", layer="parcels", driver="GPKG")
    write_checksums(stage)
    archive = shutil.make_archive(str(stage), "zip", stage.parent, stage.name)
    print(f"{region:<22} {len(part):>7,} features  {Path(archive).stat().st_size/1e6:6.1f} MB")
print(f"... {gdf['region'].nunique()} regions in all")
Barleythorpe             1,142 features     0.3 MB
Braunston & Martinsthorpe   1,906 features     0.9 MB
Cottesmore               1,011 features     0.3 MB
Exton                    1,107 features     0.5 MB
Greetham                 1,129 features     0.4 MB
... 15 regions in all

Example 3: write a zipped shapefile directly

For a single-layer deliverable, GDAL can write the archive itself. The obvious route, through its /vsizip/ virtual filesystem, is not the one that works:

gdf = gpd.read_file("data/clean/roads.gpkg")
try:
    gdf.to_file("/vsizip/build/roads.zip/roads.shp", driver="ESRI Shapefile")
except Exception as exc:
    print(f"/vsizip/: {type(exc).__name__}: {str(exc).split(':')[0]}")

gdf.to_file("build/roads.shp.zip", driver="ESRI Shapefile")     # the shapefile driver's own zip support
print("roads.shp.zip:", zipfile.ZipFile("build/roads.shp.zip").namelist(),
      f"| reads back {len(gpd.read_file('build/roads.shp.zip')):,} roads")
/vsizip/: DataLayerError: Failed to create file /vsizip/build/roads.zip/roads.shp
roads.shp.zip: ['roads.shp', 'roads.cpg', 'roads.dbf', 'roads.prj', 'roads.shx'] | reads back 7,292 roads

Name the file .shp.zip (or .shz) and the shapefile driver writes every sidecar into the archive, which removes the classic "missing .dbf" failure β€” at the cost of the metadata and checksum files a staged bundle gives you. The /vsizip/ path is fine for reading; writing a new archive through it failed here.

Example 4: publish and record what was sent

import json, shutil
from datetime import datetime, timezone
from pathlib import Path

def publish(archive: Path, dest_dir: Path, ledger: Path = Path("build/deliveries.jsonl")):
    dest_dir.mkdir(parents=True, exist_ok=True)
    final = dest_dir / archive.name
    shutil.copy2(archive, final)

    entry = {
        "archive": final.name,
        "bytes": final.stat().st_size,
        "sha256": hashlib.sha256(final.read_bytes()).hexdigest(),
        "published_utc": datetime.now(timezone.utc).isoformat(timespec="seconds"),
    }
    with open(ledger, "a", encoding="utf-8") as fh:
        fh.write(json.dumps(entry) + "\n")
    print(f"published {final} ({entry['sha256'][:12]}…)")
    return entry

archive = sorted(Path("build/delivery").glob("*.zip"))[0]
publish(archive, Path("outbox"))
print(Path("build/deliveries.jsonl").read_text().strip())
published outbox/parcels_delivery_20261010.zip (b5f6eb363713…)
{"archive": "parcels_delivery_20261010.zip", "bytes": 7654185, "sha256": "b5f6eb3637130bb5adf974d8d7512a5a540f57b9f71947c11e8eaa42770ab9ff", "published_utc": "2026-10-10T23:47:42+00:00"}

An append-only ledger answers "which version did they get, and when?" β€” the question that always arrives six months later.

What turns a file into a deliverable

The gap between an output file and a deliverable is everything a stranger needs in order to use the data without asking you a question. That includes the obvious β€” a format they can open, a CRS they can identify β€” and the less obvious: when it was made, from what, under what licence, and how to tell whether it arrived intact.

Checks between "the files exist" and "someone else can use them". seven checks before you send itEvery shapefile sidecar present.shx .dbf .prjCRS declared and correctin data + docsNo layer is emptymin feature countsField names survive the format10-byte limitVersion and date in the nameno _FINAL_v2Checksums written and verifiedSHA256SUMSBundle opened from the zipread backThe last one catches everything the other six miss spatialworkflow.io
Seven checks between "the files exist" and "someone else can use them".

Staging is the structural idea. Building the bundle as a real directory and then archiving the whole thing means the archive's contents are exactly what you inspected, sidecars and all. Hand-picking files into a zip is where shapefile components go missing, and it is unnecessary: shutil.make_archive on a folder cannot forget a file that is in the folder.

Generating the documentation is the second idea. A README typed by hand describes the bundle you meant to build; a README rendered from the same manifest the script produced describes the bundle that exists. Feature counts, CRS, extent, field lists and file names all come from the data itself, so they cannot drift β€” and they are worth including precisely because they are the questions recipients ask.

Checksums cost three lines and settle an entire class of argument. When a recipient says the file is corrupt, sha256sum -c SHA256SUMS tells you in seconds whether the problem is the transfer or the data. Deterministic zip timestamps go a step further: two builds from unchanged inputs produce identical bytes, so the archive's own hash becomes a version identifier.

The verification step is the one people skip and should not. Unpacking to a temporary folder and reading each layer back with GeoPandas exercises the whole chain β€” the export, the sidecars, the CRS, the zip β€” on the same code path the recipient will use. It is the difference between believing the bundle works and knowing it does.

Field names, zip limits and recipients' tools

  • Shapefile field names are truncated to 10 bytes: Rename deliberately before export and check for duplicates, or the recipient gets populatio and populati_1.
  • Zip has a 4 GB limit without ZIP64: Python enables ZIP64 automatically, but some older desktop tools cannot read it. Split large deliveries by region.
  • macOS archives add __MACOSX and .DS_Store: Build with Python rather than the Finder, and exclude dotfiles when staging.
  • Absolute paths inside the archive: Always add files with a relative arcname; a zip containing /home/gis/... unpacks confusingly.
  • Timestamps break byte-identical builds: Fix date_time in the ZipInfo if you need reproducible archives.
  • Encoding matters for shapefiles: Pass encoding="utf-8" on write so a .cpg is produced, or accented attribute text will come back garbled.
  • Do not put credentials in the manifest: Record the source dataset name and version, not the connection string.

FAQ

What should a GIS deliverable bundle contain?

The data in the agreed format and CRS, a machine-readable manifest, a generated README, a licence and attribution statement, and a checksum file. Anything else is a bonus; those five make it usable without a phone call.

How do I make sure shapefile sidecars are included?

Build the bundle in a staging directory and archive the whole directory. Copying a hand-picked list of files is how .dbf and .prj go missing.

Which format should I deliver in?

GeoPackage unless the recipient specifies otherwise: one file, no sidecars, UTF-8, many layers, no field-name limits. Add a shapefile copy only when they ask for one.

How do I make the zip reproducible?

Write it with zipfile using a fixed date_time in each ZipInfo and a sorted file list. Two builds from identical inputs then produce identical bytes.

Should I include a checksum file?

Yes. SHA256SUMS in the standard sha256sum -c format costs three lines to produce and immediately distinguishes a broken download from bad data.

How do I keep the README accurate?

Generate it from the same manifest the script builds. Feature counts, CRS and layer names then come from the data rather than from memory.

How do I verify a bundle before sending it?

Unpack it to a temporary directory, check every checksum, and read each layer back with GeoPandas, asserting it is non-empty and has a CRS. That exercises the same path the recipient will take.