How to Add a Dry-Run Mode to a Batch GIS Script

On this page

Problem statement

The script is ready. It will read 900 shapefiles, reproject them, clip them to a boundary, and write 900 GeoPackages into a delivery folder β€” overwriting whatever is there. You are fairly sure the paths are right.

"Fairly sure" is the problem. Run it and find out, and the failure modes are:

wrote 900 files to /home/gis/data/out          # wrong folder β€” cwd was not what you thought
wrote 12 files, then: DriverError on file 13   # 40 minutes wasted
overwrote last month's delivery                # no undo

A dry run answers "what would this do?" without doing it. For a batch job that writes hundreds of files, deletes anything, or pushes to a database, it turns an irreversible action into a reviewable plan.

What a dry run needs to show:

  • exactly which inputs were matched, and which were skipped
  • exactly which outputs would be written, and which already exist
  • which operations are destructive (overwrite, delete, upload)
  • an estimate of the time and disk the real run would need
  • any validation problems that would fail the run anyway

Quick answer

Separate planning from executing, then make the executor optional:

  1. build a plan: a list of (input, output, action) records β€” no side effects
  2. validate the plan: missing inputs, existing outputs, collisions, disk space
  3. print the plan, and stop there if --dry-run was passed
  4. execute the same plan when it was not
  5. make dry run the default for anything destructive
import argparse
from pathlib import Path

def build_plan(src: Path, out: Path):
    plan = []
    for shp in sorted(src.rglob("*.shp")):
        dest = (out / shp.relative_to(src)).with_suffix(".gpkg")
        plan.append({"src": shp, "dest": dest,
                     "action": "overwrite" if dest.exists() else "create"})
    return plan

ap = argparse.ArgumentParser()
ap.add_argument("--dry-run", action="store_true", help="show what would happen, change nothing")
args = ap.parse_args(["--dry-run"])          # in a script: ap.parse_args()

plan = build_plan(Path("data/raw"), Path("data/out"))
creates = sum(1 for p in plan if p["action"] == "create")
overwrites = len(plan) - creates
print(f"{len(plan)} operations: {creates} create, {overwrites} overwrite")

if args.dry_run:
    for p in plan[:5]:
        print(f"  [{p['action']:9}] {p['src']} β†’ {p['dest']}")
else:
    for p in plan:
        convert(p["src"], p["dest"])
18 operations: 18 create, 0 overwrite
  [create   ] data/raw/110m/cultural/admin_0_boundary_lines_land.shp β†’ data/out/110m/cultural/admin_0_boundary_lines_land.gpkg
  [create   ] data/raw/110m/cultural/admin_0_countries.shp β†’ data/out/110m/cultural/admin_0_countries.gpkg
  [create   ] data/raw/110m/cultural/admin_1_states_provinces.shp β†’ data/out/110m/cultural/admin_1_states_provinces.gpkg
  [create   ] data/raw/110m/cultural/populated_places.shp β†’ data/out/110m/cultural/populated_places.gpkg
  [create   ] data/raw/110m/physical/coastline.shp β†’ data/out/110m/physical/coastline.gpkg

The key structural move is that build_plan() has no side effects. Once planning and doing are separate functions, a dry run is one if β€” and the plan becomes testable on its own.

Plan, then execute

A script that acts as it iterates compared with one that plans then executes. why a preview is impossiblediscover + act in one loopβœ•side effects while iteratingβœ•nothing to show in advanceβœ•collisions found halfwayβœ•no way to test the logicplan, then executeβœ“the planner writes nothingβœ“the plan can be printedβœ“collisions found in a secondβœ“pure planner, easy to testSeparating the two makes --dry-run one if statement spatialworkflow.io
Interleaving discovery and side effects is what makes a script impossible to preview.

Plan as data, validate, then execute the same plan

The plan-then-execute flow with the dry-run gate. four stages are identical either way1discover inputsread only2build the plansrc β†’ dest β†’ action3validate the plancollisions, disk4report itcounts + samples5gate on --dry-runstop here, exit 06execute the same planno re-scanningThe executor consumes the plan that was printed β€” never a fresh one spatialworkflow.io
Six stages β€” the first four are identical whether or not the run is real.

Make the plan a data structure

A plan is a list of records, not a sequence of actions. That means you can count it, sort it, diff it, save it and test it.

from dataclasses import dataclass, field
from pathlib import Path

@dataclass
class Operation:
    src: Path
    dest: Path
    action: str                       # create | overwrite | skip | delete
    reason: str = ""
    size_in: int = 0
    warnings: list[str] = field(default_factory=list)

    def line(self) -> str:
        mark = {"create": "+", "overwrite": "~", "skip": ".", "delete": "-"}[self.action]
        warn = f"   ⚠ {'; '.join(self.warnings)}" if self.warnings else ""
        return f" {mark} {self.src.name:<38} β†’ {self.dest}{warn}"

The action field is what makes the report readable at a glance: a plan of 900 + lines is comfortable, and one containing 40 ~ lines deserves a second look.

Build the plan without touching anything

from pathlib import Path

def build_plan(src_root: Path, out_root: Path, overwrite: bool = False) -> list[Operation]:
    ops = []
    for shp in sorted(src_root.rglob("*.shp")):
        dest = (out_root / shp.relative_to(src_root)).with_suffix(".gpkg")
        warnings = []

        for ext in (".shx", ".dbf"):
            if not shp.with_suffix(ext).exists():
                warnings.append(f"missing {ext}")
        if not shp.with_suffix(".prj").exists():
            warnings.append("no .prj (CRS unknown)")

        if dest.exists() and not overwrite:
            action, reason = "skip", "output exists"
        elif dest.exists():
            action, reason = "overwrite", "output exists, --overwrite given"
        else:
            action, reason = "create", ""

        ops.append(Operation(shp, dest, action, reason,
                             size_in=shp.stat().st_size, warnings=warnings))
    return ops

Reading file metadata is fine β€” it changes nothing. The rule is simply that a planner may read, and must not write.

Validate the plan before showing it

import shutil
from collections import Counter

def validate(ops: list[Operation], out_root: Path) -> list[str]:
    problems = []

    dest_counts = Counter(op.dest for op in ops if op.action != "skip")
    for dest, n in dest_counts.items():
        if n > 1:
            srcs = [str(o.src) for o in ops if o.dest == dest]
            problems.append(f"{n} inputs would write to {dest}: {srcs}")

    to_write = sum(op.size_in for op in ops if op.action != "skip")
    free = shutil.disk_usage(out_root.parent if out_root.exists() else Path.cwd()).free
    if to_write * 1.5 > free:
        problems.append(f"needs ~{to_write*1.5/1e9:.1f} GB, {free/1e9:.1f} GB free")

    if not ops:
        problems.append("no inputs matched β€” check the source folder and pattern")

    return problems

Output collisions and a full disk are exactly the failures that ruin a long run halfway through. Both are detectable before the first byte is written.

What the dry run flagged for five damaged shapefile deliveries, against what the live run did. what a plan can and cannot seedeliverydry runlive runcoast.shp, no .shxwarns: missing .shxfailsocean.shp, no .dbfwarns: missing .dbfwrites, no attributeslakes.shp, no .prjwarns: no .prjwrites, no CRSland.shp, a 404 pagecreatefailsrivers.shp, cut offcreatewrites, 6 of 13 emptya plan built from the file listing sees missing parts, not bad contentsNatural Earth library plus five damaged deliveries spatialworkflow.io
The dry run caught the three problems visible in a folder listing and missed the two that are inside the files.

Report the plan usefully

Nobody reads 900 lines. Summarise, then show a sample and everything unusual.

from collections import Counter

def report(ops: list[Operation], problems: list[str], sample: int = 10) -> None:
    counts = Counter(op.action for op in ops)
    total_in = sum(op.size_in for op in ops if op.action != "skip")

    print("── plan ──────────────────────────────────────────")
    for action in ("create", "overwrite", "skip", "delete"):
        if counts.get(action):
            print(f"{action:>10}: {counts[action]:>5}")
    print(f"{'input size':>10}: {total_in/1e6:>5.1f} MB")

    print(f"\nfirst {sample} operations:")
    for op in ops[:sample]:
        print(op.line())
    if len(ops) > sample:
        print(f"  … {len(ops) - sample} more")

    flagged = [op for op in ops if op.warnings]
    if flagged:
        print(f"\n{len(flagged)} inputs with warnings:")
        for op in flagged[:sample]:
            print(op.line())

    destructive = [op for op in ops if op.action in ("overwrite", "delete")]
    if destructive:
        print(f"\n⚠ {len(destructive)} destructive operations")
        for op in destructive[:sample]:
            print(op.line())

    if problems:
        print("\nβœ— blocking problems:")
        for p in problems:
            print(f"   {p}")

Gate the execution

import sys

def main() -> int:
    args = parse_args()
    ops = build_plan(args.src, args.out, overwrite=args.overwrite)
    problems = validate(ops, args.out)
    report(ops, problems)

    if problems:
        print("\nrefusing to run β€” fix the problems above", file=sys.stderr)
        return 2

    if args.dry_run:
        print("\ndry run: nothing was written")
        return 0

    destructive = sum(1 for op in ops if op.action in ("overwrite", "delete"))
    if destructive and not args.yes and sys.stdin.isatty():
        answer = input(f"\n{destructive} files will be overwritten. Continue? [y/N] ")
        if answer.strip().lower() not in {"y", "yes"}:
            return 1

    return execute(ops)

The confirmation prompt is guarded by sys.stdin.isatty() so an unattended run never hangs waiting for input that will never come β€” a genuinely common way to lose a night's processing.

Execute the plan, unchanged

import logging

log = logging.getLogger("batch")

def execute(ops: list[Operation]) -> int:
    ok, failed = 0, []
    for i, op in enumerate((o for o in ops if o.action != "skip"), start=1):
        try:
            op.dest.parent.mkdir(parents=True, exist_ok=True)
            convert(op.src, op.dest)
            ok += 1
        except Exception as exc:
            failed.append((op.src.name, f"{type(exc).__name__}: {exc}"))
            log.warning("failed %s: %s", op.src.name, exc)
    log.info("%d written, %d failed", ok, len(failed))
    return 1 if failed else 0

The executor consumes exactly the plan that was printed. If it iterated the filesystem again, the preview and the run could differ β€” which would defeat the point.

Wire up the flags

import argparse
from pathlib import Path

def parse_args():
    ap = argparse.ArgumentParser(description="Convert shapefiles to GeoPackage")
    ap.add_argument("src", type=Path)
    ap.add_argument("out", type=Path)
    ap.add_argument("-n", "--dry-run", action="store_true",
                    help="show what would happen and exit")
    ap.add_argument("--overwrite", action="store_true",
                    help="replace existing outputs instead of skipping them")
    ap.add_argument("-y", "--yes", action="store_true",
                    help="skip the confirmation prompt (for unattended runs)")
    ap.add_argument("--plan-json", type=Path, help="write the plan as JSON")
    return ap.parse_args()

With the pieces defined, the dry run on the library and its damaged deliveries:

ops = build_plan(Path("data/raw"), Path("data/out"))
report(ops, validate(ops, Path("data/out")), sample=3)
── plan ──────────────────────────────────────────
    create:    18
input size:   3.8 MB

first 3 operations:
 + admin_0_boundary_lines_land.shp        β†’ data/out/110m/cultural/admin_0_boundary_lines_land.gpkg
 + admin_0_countries.shp                  β†’ data/out/110m/cultural/admin_0_countries.gpkg
 + admin_1_states_provinces.shp           β†’ data/out/110m/cultural/admin_1_states_provinces.gpkg
  … 15 more

3 inputs with warnings:
 + coast.shp                              β†’ data/out/delivered/coast.gpkg   ⚠ missing .shx
 + lakes.shp                              β†’ data/out/delivered/lakes.gpkg   ⚠ no .prj (CRS unknown)
 + ocean.shp                              β†’ data/out/delivered/ocean.gpkg   ⚠ missing .dbf

The plan flags what can be seen from the file listing β€” the missing .shx, the missing .dbf and .prj. It cannot see that land.shp is a 404 page or that rivers.shp stops halfway: both have all their parts, at plausible sizes. A dry run reports what the job intends; only reading the files finds what is in them, so a validating dry run has to open each one β€” which is the step the live run then repeats.

-n for dry run follows rsync, make and git clean; users will guess it correctly.

Previewing writes to a nested shapefile library

The data used on this page

A real nested library of shapefiles: thirteen layers of Natural Earth (public domain), filed by scale and theme as data/raw/<scale>/<theme>/<layer>.shp β€” so 110m/cultural/admin_0_countries.shp and 50m/cultural/admin_0_countries.shp are different files with the same name. A delivered/ folder holds five more, damaged on purpose the ways deliveries really arrive: coast.shp without its .shx, ocean.shp without its .dbf, lakes.shp without its .prj, rivers.shp cut off halfway, and land.shp, which is a web server's 404 page saved under a shapefile's name.

import io
import shutil
import zipfile
from pathlib import Path

import geopandas as gpd
import requests

NE = {"cultural": ["ne_110m_admin_0_countries", "ne_110m_admin_1_states_provinces", "ne_110m_populated_places",
                   "ne_110m_admin_0_boundary_lines_land", "ne_50m_admin_0_countries", "ne_50m_populated_places"],
      "physical": ["ne_110m_coastline", "ne_110m_land", "ne_110m_ocean", "ne_110m_rivers_lake_centerlines",
                   "ne_110m_lakes", "ne_110m_glaciated_areas", "ne_110m_geography_regions_polys"]}
raw = Path("data/raw")
for d in ("data/out", "data/raw/delivered"):
    shutil.rmtree(d, ignore_errors=True)              # every run of the page starts from nothing
for theme, names in NE.items():
    for name in names:
        scale, layer = name.split("_")[1], name.split("_", 2)[2]
        folder = raw / scale / theme
        if not (folder / f"{layer}.shp").exists():
            reply = requests.get(f"https://naciscdn.org/naturalearth/{scale}/{theme}/{name}.zip", timeout=120)
            reply.raise_for_status()
            with zipfile.ZipFile(io.BytesIO(reply.content)) as z:
                for member in z.namelist():
                    if member.startswith(name + "."):
                        folder.mkdir(parents=True, exist_ok=True)
                        (folder / member.replace(name, layer)).write_bytes(z.read(member))
# five deliveries damaged on purpose, each the way files really arrive damaged
bad = raw / "delivered"
bad.mkdir()
def deliver(layer, name, parts):
    for ext in parts:
        shutil.copy(raw / "110m" / "physical" / f"{layer}.{ext}", bad / f"{name}.{ext}")
deliver("coastline", "coast", ["shp", "dbf", "prj"])                  # no .shx
deliver("ocean", "ocean", ["shp", "shx", "prj"])                      # no .dbf
deliver("lakes", "lakes", ["shp", "shx", "dbf"])                      # no .prj
deliver("rivers_lake_centerlines", "rivers", ["shp", "shx", "dbf", "prj"])
data = (bad / "rivers.shp").read_bytes()
(bad / "rivers.shp").write_bytes(data[: len(data) // 2])              # cut off mid-copy
deliver("land", "land", ["shx", "dbf", "prj"])
(bad / "land.shp").write_text("<html><body><h1>404 Not Found</h1></body></html>")

print(len(sorted(raw.rglob("*.shp"))), "shapefiles in", sorted({str(p.parent.relative_to(raw)) for p in raw.rglob("*.shp")}))
18 shapefiles in ['110m/cultural', '110m/physical', '50m/cultural', 'delivered']

Example 1: the complete pattern

%%writefile convert.py
#!/usr/bin/env python3
"""convert.py β€” plan, review, then convert."""
from dataclasses import dataclass, field, asdict
from pathlib import Path
from collections import Counter
import argparse, json, logging, shutil, sys
import geopandas as gpd

log = logging.getLogger("convert")

@dataclass
class Operation:
    src: Path
    dest: Path
    action: str
    size_in: int = 0
    warnings: list[str] = field(default_factory=list)

def build_plan(src_root: Path, out_root: Path, overwrite: bool) -> list[Operation]:
    ops = []
    for shp in sorted(src_root.rglob("*.shp")):
        dest = (out_root / shp.relative_to(src_root)).with_suffix(".gpkg")
        warnings = [f"missing {e}" for e in (".shx", ".dbf") if not shp.with_suffix(e).exists()]
        action = "create" if not dest.exists() else ("overwrite" if overwrite else "skip")
        ops.append(Operation(shp, dest, action, shp.stat().st_size, warnings))
    return ops

def convert(src: Path, dest: Path) -> None:
    gdf = gpd.read_file(src)
    dest.parent.mkdir(parents=True, exist_ok=True)
    tmp = dest.with_suffix(".part.gpkg")         # GDAL wants a .gpkg extension even on a temp file
    gdf.to_file(tmp, driver="GPKG")
    tmp.replace(dest)

def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("src", type=Path); ap.add_argument("out", type=Path)
    ap.add_argument("-n", "--dry-run", action="store_true")
    ap.add_argument("--overwrite", action="store_true")
    ap.add_argument("-y", "--yes", action="store_true")
    ap.add_argument("--plan-json", type=Path)
    args = ap.parse_args()
    logging.basicConfig(format="%(levelname)s %(message)s")
    log.setLevel(logging.INFO)                   # our messages, not every library's

    if not args.src.is_dir():
        print(f"source not found: {args.src.resolve()}", file=sys.stderr)
        return 2

    ops = build_plan(args.src, args.out, args.overwrite)
    counts = Counter(o.action for o in ops)
    dupes = [d for d, n in Counter(o.dest for o in ops if o.action != "skip").items() if n > 1]

    print(f"source : {args.src}")
    print(f"target : {args.out}")
    print(f"plan   : {dict(counts)}  ({sum(o.size_in for o in ops)/1e6:.1f} MB in)")
    for op in ops[:10]:
        print(f"  {op.action:>9}  {op.src.name} β†’ {op.dest}")
    if len(ops) > 10:
        print(f"  … {len(ops)-10} more")

    if args.plan_json:
        args.plan_json.write_text(json.dumps(
            [{**asdict(o), "src": str(o.src), "dest": str(o.dest)} for o in ops], indent=2))

    if dupes:
        print(f"\nβœ— {len(dupes)} output collisions", file=sys.stderr)
        return 2
    if args.dry_run:
        print("\ndry run: nothing written")
        return 0
    if counts.get("overwrite") and not args.yes and sys.stdin.isatty():
        answer = input(f"overwrite {counts['overwrite']} files? [y/N] ")
        if answer.strip().lower() not in {"y", "yes"}:
            return 1

    failed = []
    for op in (o for o in ops if o.action != "skip"):
        try:
            convert(op.src, op.dest)
        except Exception as exc:
            failed.append((op.src.name, str(exc)))
            log.warning("failed %s: %s", op.src.name, exc)
    written = len(ops) - counts.get("skip", 0) - len(failed)
    log.info("done: %d written, %d failed", written, len(failed))
    return 1 if failed else 0

if __name__ == "__main__":
    raise SystemExit(main())
import sys
!{sys.executable} convert.py data/raw data/out --dry-run
!{sys.executable} -u -W ignore convert.py data/raw data/out --yes 2>&1 | tail -3
!{sys.executable} convert.py data/raw data/out --dry-run | grep "plan"
source : data/raw
target : data/out
plan   : {'create': 18}  (3.8 MB in)
     create  admin_0_boundary_lines_land.shp β†’ data/out/110m/cultural/admin_0_boundary_lines_land.gpkg
     create  admin_0_countries.shp β†’ data/out/110m/cultural/admin_0_countries.gpkg
     create  admin_1_states_provinces.shp β†’ data/out/110m/cultural/admin_1_states_provinces.gpkg
     create  populated_places.shp β†’ data/out/110m/cultural/populated_places.gpkg
     create  coastline.shp β†’ data/out/110m/physical/coastline.gpkg
     create  geography_regions_polys.shp β†’ data/out/110m/physical/geography_regions_polys.gpkg
     create  glaciated_areas.shp β†’ data/out/110m/physical/glaciated_areas.gpkg
     create  lakes.shp β†’ data/out/110m/physical/lakes.gpkg
     create  land.shp β†’ data/out/110m/physical/land.gpkg
     create  ocean.shp β†’ data/out/110m/physical/ocean.gpkg
  … 8 more

dry run: nothing written
WARNING failed coast.shp: Unable to open data/raw/delivered/coast.shx or data/raw/delivered/coast.SHX. Set SHAPE_RESTORE_SHX config option to YES to restore or create it.
WARNING failed land.shp: 'data/raw/delivered/land.shp' not recognized as being in a supported file format.; It might help to specify the correct driver explicitly by prefixing the file path with '<DRIVER>:', e.g. 'CSV:path'.
INFO done: 16 written, 2 failed
plan   : {'skip': 16, 'create': 2}  (3.8 MB in)

Dry run, live run, dry run again. The live run fails exactly the two files GDAL cannot open β€” the missing .shx the plan had warned about, and the 404 page it could not see β€” and writes the other sixteen, including the ones the plan warned about that still read. The second dry run is the cheapest resume check there is: sixteen skips and two creates, the two that failed. The temporary file is named *.part.gpkg rather than *.gpkg.part because GDAL's GeoPackage driver warns on any other extension; the first version of this script used .gpkg.part and printed that warning once per file.

Example 2: dry-run a database write

Filesystem operations are easy to preview; database writes need the same discipline.

from sqlalchemy import create_engine, text

def load_to_postgis(gdf, table, engine, dry_run=False):
    with engine.connect() as conn:
        exists = conn.execute(text(
            "SELECT to_regclass(:t) IS NOT NULL"), {"t": f"public.{table}"}).scalar()
        current = conn.execute(text(f"SELECT count(*) FROM {table}")).scalar() if exists else 0

    print(f"{'would ' if dry_run else ''}write {len(gdf)} rows to {table} "
          f"({'replacing' if exists else 'creating'}, currently {current} rows)")
    if dry_run:
        return {"rows": len(gdf), "table": table, "existing_rows": current, "applied": False}

    gdf.to_postgis(table, engine, if_exists="replace", index=False)
    return {"rows": len(gdf), "table": table, "existing_rows": current, "applied": True}

import os, time
if os.system("docker inspect guide-postgis >/dev/null 2>&1") != 0:
    os.system("docker run -d --name guide-postgis -e POSTGRES_USER=user -e POSTGRES_PASSWORD=guide "
              "-e POSTGRES_DB=gis -p 127.0.0.1:55433:5432 postgis/postgis:16-3.4 >/dev/null")
    time.sleep(10)
engine = create_engine("postgresql+psycopg://user:guide@localhost:55433/gis")
countries = gpd.read_file("data/raw/110m/cultural/admin_0_countries.shp")
print(load_to_postgis(countries, "countries_dry", engine, dry_run=True))
print(load_to_postgis(countries, "countries_dry", engine))
print(load_to_postgis(countries, "countries_dry", engine, dry_run=True))
would write 177 rows to countries_dry (creating, currently 0 rows)
{'rows': 177, 'table': 'countries_dry', 'existing_rows': 0, 'applied': False}
write 177 rows to countries_dry (creating, currently 0 rows)
{'rows': 177, 'table': 'countries_dry', 'existing_rows': 0, 'applied': True}
would write 177 rows to countries_dry (replacing, currently 177 rows)
{'rows': 177, 'table': 'countries_dry', 'existing_rows': 177, 'applied': False}

Returning the same record shape in both modes means the caller's reporting code does not branch.

Example 3: transaction-style dry run for a whole pipeline

class Runner:
    """Collects intended side effects; applies them only when live."""

    def __init__(self, dry_run: bool = True):
        self.dry_run = dry_run
        self.actions = []

    def write(self, gdf, dest, **kwargs):
        self.actions.append(("write", str(dest), len(gdf)))
        if not self.dry_run:
            Path(dest).parent.mkdir(parents=True, exist_ok=True)
            gdf.to_file(dest, **kwargs)

    def delete(self, path):
        self.actions.append(("delete", str(path), None))
        if not self.dry_run:
            Path(path).unlink(missing_ok=True)

    def summary(self):
        from collections import Counter
        counts = Counter(a[0] for a in self.actions)
        prefix = "would " if self.dry_run else ""
        return f"{prefix}{dict(counts)}"

clean = gpd.read_file("data/raw/110m/physical/lakes.shp")
runner = Runner(dry_run=args.dry_run)
runner.write(clean, "data/out/lakes_clean.gpkg", driver="GPKG")
runner.delete("data/out/lakes_clean.gpkg.part")
print(runner.summary())
print("written:", Path("data/out/lakes_clean.gpkg").exists())
would {'write': 1, 'delete': 1}
written: False

One object owns every side effect, so --dry-run is enforced in a single place instead of being remembered at each call site.

Example 4: test the planner without any data

%%writefile test_convert.py
from pathlib import Path

from convert import build_plan

def test_plan_marks_existing_outputs_as_skip(tmp_path):
    src, out = tmp_path / "in", tmp_path / "out"
    (src / "north").mkdir(parents=True)
    for ext in (".shp", ".shx", ".dbf"):
        (src / "north" / f"roads{ext}").write_bytes(b"")
    (out / "north").mkdir(parents=True)
    (out / "north" / "roads.gpkg").write_bytes(b"")

    ops = build_plan(src, out, overwrite=False)
    assert len(ops) == 1
    assert ops[0].action == "skip"
    assert ops[0].dest == out / "north" / "roads.gpkg"
!{sys.executable} -m pytest -q --color=no -p no:cacheprovider test_convert.py
.                                                                        [100%]
1 passed in 0.20s

Because the planner is pure, the whole preview logic is testable with empty files and no GIS stack at all.

Separating deciding from doing

Most batch scripts interleave three things β€” discovering work, deciding what to do, and doing it β€” inside one loop. That structure makes a preview impossible: you cannot show what the script will do without running the loop, and running the loop is the thing you wanted to avoid.

What a useful dry run reports before a real run. what a dry run should tell youCounts by action: create, overwrite, skipsummaryA sample of the operationsfirst 10Every destructive operation, listedin fullInputs with warningsmissing sidecarsOutput path collisionsblockingEstimated disk space neededblockingA plan you can read is a plan someone else can approve spatialworkflow.io
What a useful dry run tells you β€” counts, collisions, destructiveness, capacity.

Splitting the script into a planner and an executor fixes it structurally. The planner reads the filesystem and produces a list of intended operations; the executor consumes that list. --dry-run then means "stop after the planner", and because both modes use the same plan, the preview is exactly what will happen rather than an approximation of it.

That separation pays off in several other ways. The plan can be counted and summarised, so you learn that 40 of your 900 outputs already exist before the run rather than after. It can be validated: two inputs mapping to one output is a bug you can catch statically, as is not having the disk space for the result. It can be serialised to JSON and attached to a ticket or diffed against last month's. And it can be unit-tested with empty files, because a pure planner needs no real data.

The last consideration is defaults. For anything reversible, live is a fine default. For a script that overwrites a delivery folder, deletes anything, publishes to a server, or writes to a production database, the safer default is dry run with an explicit --apply to commit β€” the convention terraform, kubectl and most infrastructure tools converged on for the same reason. Whichever you choose, guard interactive confirmations with sys.stdin.isatty(), or the first scheduled run will hang forever on a prompt nobody can see.

Dry runs that still write, and plans that go stale

  • A dry run must not write anything β€” including logs into the output folder: Keep run logs somewhere separate, or a "preview" creates directories.
  • Plans go stale: If files can change between planning and execution, re-check dest.exists() at write time, or run both phases back to back.
  • --dry-run in a scheduler is a no-op job: Make sure the exit code says "nothing was done", and never let a scheduled run silently stay in preview mode.
  • Prompts hang unattended jobs: Guard input() with sys.stdin.isatty() and provide --yes for automation.
  • Deletes deserve extra care: Show the full list of paths that would be removed, not a count, and consider moving to a trash folder rather than unlinking.
  • Estimating output size is guesswork: Input bytes are a poor proxy for output bytes when the format changes. State it as an estimate.
  • Reproducibility: Sort the plan deterministically so two dry runs of unchanged data produce byte-identical output that can be diffed.

FAQ

What exactly should a dry run print?

Counts by action (create, overwrite, skip, delete), a sample of the operations, every destructive one, every input with a warning, and any blocking problem such as an output collision or insufficient disk space.

Should dry run be the default?

For destructive jobs β€” overwriting deliveries, deleting files, writing to production databases β€” yes, with an explicit --apply to commit. For safe, idempotent jobs, live is a reasonable default with -n available.

How do I dry-run a database load?

Query for the table's existence and current row count, report what would be written, and return before calling to_postgis. Keep the return shape identical in both modes so callers do not branch.

How do I stop the preview and the real run drifting apart?

Have the executor consume the plan object rather than re-scanning the filesystem. One list, produced once, used by both paths.

Will a confirmation prompt break my scheduled job?

Yes, unless you guard it. Only prompt when sys.stdin.isatty() is true, and offer --yes so automation can pass explicitly.

Can I keep the plan for the record?

Write it as JSON alongside the run log. It diffs cleanly against previous runs and answers "what did last Tuesday's job actually intend to do?" months later.

How do I test the dry-run logic?

Because the planner has no side effects, you can build a temporary tree of empty files and assert on the operations it produces β€” no GIS data or dependencies required.