How to Package GIS Deliverables into Zipped Bundles with Python

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"{archive}  {Path(archive).stat().st_size/1e6:.1f} 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

Layered anatomy of a deliverable bundle: data, metadata, documentation, checksums.
Four layers β€” only the first is the thing people ask for, and all four are what they need.

Step-by-step solution

Vertical steps: export, document, checksum, archive, verify, publish.
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": "Contains OS data Β© Crown copyright and database right 2026",
    "licence": "OGL v3.0",
}

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

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.

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 []

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

Fixing the timestamps means two builds of the same data produce the same bytes, so a checksum tells you whether the data changed rather than when the zip 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

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

Code examples

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}  ({archive.stat().st_size/1e6:.1f} MB)")
    return archive

if __name__ == "__main__":
    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()
    try:
        archive = build(DELIVERY, args.out, args.dry_run)
    except Exception as exc:
        print(f"packaging failed: {exc}", file=sys.stderr)
        raise SystemExit(1)
    if archive:
        report = verify_bundle(archive)
        print("verification:", "ok" if report["ok"] else report["problems"])
        raise SystemExit(0 if report["ok"] else 1)

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 gdf.groupby("region"):
    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:<18} {len(part):>7,} features  {Path(archive).stat().st_size/1e6:6.1f} MB")

Example 3: write straight into a zip with GDAL's virtual filesystem

For a single-layer deliverable, GDAL can write inside the archive directly.

import geopandas as gpd

gdf = gpd.read_file("data/clean/roads.gpkg")
gdf.to_file("/vsizip//home/gis/build/roads.zip/roads.shp", driver="ESRI Shapefile")

All sidecars land inside the zip, which removes the classic "missing .dbf" failure β€” at the cost of the metadata and checksum files a staged bundle gives you.

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

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

Explanation

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.

Checklist of what a complete GIS deliverable bundle contains.
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.

Edge cases or notes

  • 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.