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
.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"{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
Contract, export, metadata, checksums, archive
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.
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
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
- A shared file still contains the original coordinates β what travels in the zip
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.