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
.prjthat disagrees with the documentation - no version, date, or provenance β two bundles that cannot be told apart
- absolute paths or
__MACOSXjunk 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:
- export each layer to the agreed format and CRS
- write a manifest (layer list, counts, CRS, extent) and a README
- add a checksum file for every payload file
- zip the staging folder with a versioned name
- 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
Step-by-step solution
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.
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
populatioandpopulati_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
__MACOSXand.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_timein theZipInfoif you need reproducible archives. - Encoding matters for shapefiles: Pass
encoding="utf-8"on write so a.cpgis 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.
Internal links
- How to Batch Export a GeoDataFrame to Multiple Formats in Python
- How to Build an Inventory of a GIS Data Folder in Python
- How to Reduce GIS File Size in Python Without Wrecking the Data
- How to Record Run Metadata and Data Lineage in a GIS Pipeline
- How to Make a GIS Workflow Reproducible in Python
- Why Are My Shapefile Column Names Truncated? How to Fix It
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.