Idempotency Explained: Why a GIS Job Must Be Safe to Re-run

On this page

Problem statement

The nightly job failed at 02:47. You re-run it at 09:15 and make things worse. These are the runs further down this page, on HM Land Registry's 21,253 freehold parcels for Rutland:

$ python monthly_load.py                       # to_postgis(..., if_exists="append")
after run 1: 21,253 rows
$ python monthly_load.py                       # after fixing the source file
after run 2: 42,506 rows

Or the file version, three runs of a job that names its output after the clock or numbers it to avoid a clash:

data/out/naming/
  numbered_2026-08-11.gpkg
  numbered_2026-08-11_1.gpkg
  numbered_2026-08-11_1_2.gpkg        # which one is the real one?
  stamped_20260930_101439.gpkg
  stamped_20260930_101441.gpkg
  stamped_20260930_101443.gpkg

The job is not idempotent: running it twice does not leave the same state as running it once. That single property decides whether a failure is a two-minute retry or a manual clean-up, whether a scheduler may retry automatically, and whether anyone can safely re-run last month to reproduce a number.

Quick answer

Make every effect replaceable rather than additive:

  1. Replace, do not append β€” overwrite the output, or delete-then-insert within a key
  2. Derive output names deterministically β€” same inputs, same paths, no counters or timestamps-of-now
  3. Write atomically β€” to a temporary name, then rename, so a failure leaves no partial output
  4. Make "already done" cheap to detect β€” a marker, a checksum, or a state file
  5. Take time as an input β€” a run date parameter, never datetime.now() inside the logic
from pathlib import Path
import geopandas as gpd

def load(gdf: gpd.GeoDataFrame, run_date: str) -> Path:
    dest = Path(f"data/out/parcels_{run_date}.gpkg")     # deterministic from an input
    tmp = dest.parent / ".partial" / dest.name            # same filesystem, same extension
    tmp.parent.mkdir(parents=True, exist_ok=True)
    gdf.to_file(tmp, driver="GPKG")                       # write aside
    tmp.replace(dest)                                     # atomic replace
    return dest

# running this twice with the same run_date produces exactly one file,
# with exactly the contents of the second run

The mental test: if the job is killed at any point and restarted, is the world still consistent? If yes, it is idempotent; if you have to check what state things are in first, it is not.

What "safe to re-run" means

An additive job that doubles data compared with a replacing job that converges. run it twicenot idempotentβœ•INSERT appends every runβœ•48,201 rows becomes 96,402βœ•timestamped file names pile upβœ•a retry needs manual clean-upidempotentβœ“delete-then-insert by run keyβœ“48,201 rows stays 48,201βœ“the output path is derivedβœ“a retry is simply safeThe property that makes retries, backfills and reproduction possible spatialworkflow.io
Same code path, twice β€” one converges on the right state, the other accumulates.

Replace, write atomically, and make done detectable

Side effects and the idempotent pattern for each. every side effect has a safe formnaiveidempotentfilesβœ•timestamped namesβœ“derived path + renamedatabase rowsβœ•plain INSERTβœ“delete-then-insertexternal APIβœ•POST every runβœ“idempotency keynotificationsβœ•send on each retryβœ“guard with a sent setReplace instead of accumulate; parameterise instead of using the clock spatialworkflow.io
Every kind of side effect has an idempotent form β€” and a naive form that breaks on retry.

Files: replace, and make the name a function of the inputs

Three ways of naming the output, each run three times against the same 21,253 Rutland parcels:

import time
from datetime import datetime
from pathlib import Path

out = Path("data/out/naming")
out.mkdir(parents=True, exist_ok=True)

def stamped(gdf):                      # not idempotent β€” a new file every run
    dest = out / f"stamped_{datetime.now():%Y%m%d_%H%M%S}.gpkg"
    gdf.to_file(dest, driver="GPKG")

def numbered(gdf, run_date):           # not idempotent β€” versions accumulate
    dest = out / f"numbered_{run_date}.gpkg"
    n = 1
    while dest.exists():
        dest = dest.with_stem(f"{dest.stem}_{n}"); n += 1
    gdf.to_file(dest, driver="GPKG")

def derived(gdf, run_date):            # idempotent β€” same inputs, same path, replaced
    gdf.to_file(out / f"derived_{run_date}.gpkg", driver="GPKG")

for attempt in range(3):
    stamped(gdf); numbered(gdf, run_date); derived(gdf, run_date)
    time.sleep(1)

for path in sorted(out.iterdir()):
    print(path.name)
derived_2026-08-11.gpkg
numbered_2026-08-11.gpkg
numbered_2026-08-11_1.gpkg
numbered_2026-08-11_1_2.gpkg
stamped_20261011_011526.gpkg
stamped_20261011_011528.gpkg
stamped_20261011_011530.gpkg

Three runs, seven files. The timestamped name gives one file per run and no way to tell which is authoritative, and the numbering loop is worse than it looks: dest.stem already carries the _1 from the previous pass, so the names grow _1_2 rather than counting. Only the derived name leaves one file. The rule is that the output path must be derivable from the inputs and parameters, not from the clock at the moment of writing; then a second run targets the same path and replaces it.

Atomic writes: never leave a half-file

To see what a crash leaves behind, each writer below runs in a child process that is killed with SIGKILL 0.4 seconds after it starts writing β€” about a third of the way through a 21,253-parcel GeoPackage:

import os, signal, subprocess, sys, textwrap
from pathlib import Path
import geopandas as gpd

def write_atomic(gdf, dest: Path, driver="GPKG") -> Path:
    tmp = dest.parent / ".partial" / dest.name
    tmp.parent.mkdir(parents=True, exist_ok=True)
    gdf.to_file(tmp, driver=driver)
    tmp.replace(dest)          # atomic rename on the same filesystem
    return dest

def killed_midway(dest: Path, atomic: bool) -> Path:
    target = dest.parent / ".partial" / dest.name if atomic else dest
    child = textwrap.dedent(f"""
        import geopandas as gpd
        from pathlib import Path
        gdf = gpd.read_file("data/raw/parcels.gpkg")
        dest, target = Path("{dest}"), Path("{target}")
        target.parent.mkdir(parents=True, exist_ok=True)
        gdf.to_file(target, driver="GPKG")
        if target != dest:
            target.replace(dest)
    """)
    proc = subprocess.Popen([sys.executable, "-c", child])
    while not target.exists():             # wait until the write has begun
        time.sleep(0.001)
    time.sleep(0.05)
    proc.send_signal(signal.SIGKILL)
    proc.wait()
    return target

for atomic in (False, True):
    dest = Path(f"data/out/crash/{'atomic' if atomic else 'direct'}.gpkg")
    for leftover in dest.parent.rglob("*"):
        if leftover.is_file():
            leftover.unlink()
    killed_midway(dest, atomic)
    left = [f"{p.relative_to(dest.parent)} ({p.stat().st_size / 1e3:,.0f} kB)"
            for p in sorted(dest.parent.rglob("*")) if p.is_file()]
    try:
        state = f"{len(gpd.read_file(dest)):,} features"
    except Exception as exc:
        state = "read fails: " + str(exc).split(": ")[-1].split(";")[0]
    print(f"{'atomic' if atomic else 'direct'}: left {left}\n        {dest.name}: {state}")
    if not atomic:
        import sqlite3
        with sqlite3.connect(dest) as db:          # a writable open replays the journal
            layers = [row[0] for row in db.execute("SELECT table_name FROM gpkg_contents")]
        print(f"        after a writable open: {dest.stat().st_size / 1e3:,.0f} kB, layers {layers}")
direct: left ['direct.gpkg (5,874 kB)', 'direct.gpkg-journal (46 kB)']
        direct.gpkg: read fails: attempt to write a readonly database
        after a writable open: 66 kB, layers []
atomic: left ['.partial/atomic.gpkg (5,612 kB)', '.partial/atomic.gpkg-journal (46 kB)']
        atomic.gpkg: read fails: No such file or directory

The direct write leaves a file at the real name β€” the thing every "does the output exist?" check looks for β€” and it is not the dataset. A GeoPackage is a SQLite database, so what is left is a half-written file plus the rollback journal SQLite uses to undo an unfinished transaction. A read-only open refuses it. Anything that opens it for writing replays the journal, and what that recovers is a 66 kB GeoPackage with no layer in it at all. The atomic version leaves its debris in .partial/, which no downstream step reads and the next run overwrites, and the real name does not exist until the rename. The destination only ever appears complete.

Databases: delete-then-insert inside a transaction

First, the load from the top of the page: an append run twice.

from sqlalchemy import text

def count(table: str, where: str = "true") -> int:
    with engine.connect() as conn:
        return conn.execute(text(f"SELECT count(*) FROM {table} WHERE {where}")).scalar()

with engine.begin() as conn:
    conn.execute(text("DROP TABLE IF EXISTS parcels_naive, parcels_slices, parcels_upsert"))

for attempt in range(2):
    gdf.to_postgis("parcels_naive", engine, if_exists="append", index=False)
    print(f"after run {attempt + 1}: {count('parcels_naive'):,} rows")
after run 1: 21,253 rows
after run 2: 42,506 rows

The fix is to make each run own a slice of the table and replace it:

def load_partition(gdf, engine, table: str, run_date: str) -> int:
    """Replace exactly this run's slice β€” safe to repeat."""
    with engine.begin() as conn:                      # one transaction
        if conn.execute(text("SELECT to_regclass(:t)"), {"t": table}).scalar():
            conn.execute(text(f"DELETE FROM {table} WHERE run_date = :d"), {"d": run_date})
        gdf.assign(run_date=run_date).to_postgis(table, conn, if_exists="append", index=False)
        return conn.execute(
            text(f"SELECT count(*) FROM {table} WHERE run_date = :d"), {"d": run_date}
        ).scalar()

for attempt in range(3):
    print(f"run {attempt + 1}: slice {load_partition(gdf, engine, 'parcels_slices', run_date):,}"
          f"  table {count('parcels_slices'):,}")
load_partition(gdf, engine, "parcels_slices", "2026-09-11")
print(f"after a second run date: table {count('parcels_slices'):,}")
run 1: slice 21,253  table 21,253
run 2: slice 21,253  table 21,253
run 3: slice 21,253  table 21,253
after a second run date: table 42,506

The pattern is delete the slice this run owns, then insert it, both inside one transaction. The to_regclass check matters: without it the very first run β€” the one with no table yet β€” fails on the DELETE with UndefinedTable. Passing the open connection to to_postgis keeps the insert in the same transaction as the delete, which is what makes a failure roll back to the previous state (Example 2 shows it). if_exists="replace" also works when the run owns the whole table.

An upsert achieves the same at row level, keyed on the Land Registry's INSPIREID:

import shapely

with engine.begin() as conn:
    conn.execute(text("""CREATE TABLE parcels_upsert (
        parcel_id bigint PRIMARY KEY, valid_from timestamptz,
        geom geometry(Polygon, 27700), updated_at date)"""))

rows = [{"parcel_id": int(r.INSPIREID), "valid_from": r.VALIDFROM,
         "geom": shapely.to_wkb(r.geometry), "updated_at": run_date}
        for r in gdf.itertuples()]

upsert = text("""
INSERT INTO parcels_upsert (parcel_id, valid_from, geom, updated_at)
VALUES (:parcel_id, :valid_from, ST_GeomFromWKB(:geom, 27700), :updated_at)
ON CONFLICT (parcel_id) DO UPDATE
SET valid_from = EXCLUDED.valid_from, geom = EXCLUDED.geom, updated_at = EXCLUDED.updated_at""")

for attempt in range(2):
    start = time.perf_counter()
    with engine.begin() as conn:
        conn.execute(upsert, rows)
    print(f"run {attempt + 1}: {count('parcels_upsert'):,} rows  ({time.perf_counter() - start:.1f} s)")
run 1: 21,253 rows  (0.3 s)
run 2: 21,253 rows  (0.3 s)
What five non-idempotent patterns left behind when re-run on 21,253 Rutland parcels. what the re-run left behindpatternleft behindto_postgis append, twice42,506 rows for 21,253 parcelsnumbered file name, 3 runsthree files: _1, then _1_2write killed at 50 msunreadable; 66 kB, no layerhash(message) as sent key3 values in 3 processessame GPKG written againnew last_change, new hashEach one only shows on the second run spatialworkflow.io
Measured on this page. Each failure only shows up on the second run.

Make "already done" detectable

import hashlib, json, os
from pathlib import Path

STATE = Path("logs/state.json")

def input_fingerprint(paths) -> str:
    h = hashlib.sha256()
    for p in sorted(Path(x) for x in paths):
        stat = p.stat()
        h.update(f"{p.name}:{stat.st_size}:{int(stat.st_mtime)}".encode())
    return h.hexdigest()[:16]

def already_done(step: str, fingerprint: str) -> bool:
    if not STATE.exists():
        return False
    return json.loads(STATE.read_text()).get(step) == fingerprint

def mark_done(step: str, fingerprint: str) -> None:
    state = json.loads(STATE.read_text()) if STATE.exists() else {}
    state[step] = fingerprint
    STATE.parent.mkdir(parents=True, exist_ok=True)
    STATE.write_text(json.dumps(state, indent=2))

def run_clean():
    parcels = gpd.read_file("data/raw/parcels.gpkg")
    ward = gpd.read_file("data/ref/oakham.gpkg").to_crs(parcels.crs)
    gpd.clip(parcels, ward).to_file("data/out/clean_parcels.gpkg")
inputs = ["data/raw/parcels.gpkg", "data/ref/oakham.gpkg"]

def step():
    fingerprint = input_fingerprint(inputs)
    if already_done("clean_parcels", fingerprint):
        return "inputs unchanged β€” skipping"
    run_clean()
    mark_done("clean_parcels", fingerprint)
    return "ran"

print("first run: ", step())
print("second run:", step())
os.utime(inputs[0])                      # same bytes, new mtime: what a re-download does
print("after touch:", step())
first run:  ran
second run: inputs unchanged β€” skipping
after touch: ran

The metadata fingerprint is cheap and conservative: it re-runs whenever the file might have changed, and a re-download of byte-identical data counts as a change. Hashing the contents instead costs a read of every input:

def content_fingerprint(paths) -> str:
    h = hashlib.sha256()
    for p in sorted(Path(x) for x in paths):
        with open(p, "rb") as f:
            for block in iter(lambda: f.read(1 << 20), b""):
                h.update(block)
    return h.hexdigest()[:16]

size = sum(Path(p).stat().st_size for p in inputs)
start = time.perf_counter()
before = content_fingerprint(inputs)
elapsed = time.perf_counter() - start
os.utime(inputs[0])
print(f"hashed {size / 1e6:.1f} MB in {elapsed * 1000:.0f} ms; unchanged after touch: "
      f"{content_fingerprint(inputs) == before}")
hashed 21.1 MB in 12 ms; unchanged after touch: True

This is idempotency turned into a performance feature: a re-run with unchanged inputs becomes a no-op rather than repeated work.

Take the clock as a parameter

The Land Registry records when each parcel's current version began (BEGINLIFESPANVERSION). A job that selects "parcels changed this month":

import argparse
from datetime import date
import pandas as pd

def month_start(day: date) -> pd.Timestamp:
    return pd.Timestamp(day.replace(day=1), tz="UTC")

# not reproducible: the result depends on when you ran it
def changed_this_month_bad(gdf):
    return gdf[gdf["BEGINLIFESPANVERSION"] >= month_start(date.today())]

# reproducible: the same run_date always selects the same rows
def changed_since(gdf, run_date: date):
    return gdf[gdf["BEGINLIFESPANVERSION"] >= month_start(run_date)]

ap = argparse.ArgumentParser()
ap.add_argument("--run-date", type=date.fromisoformat, default=date.today())
args = ap.parse_args(["--run-date", "2023-03-15"])     # a backfill for March 2023

print("newest version in the file:", gdf["BEGINLIFESPANVERSION"].max().date())
print("wall clock:  ", len(changed_this_month_bad(gdf)), "parcels")
print("--run-date:  ", len(changed_since(gdf, args.run_date)), "parcels")
newest version in the file: 2023-03-02
wall clock:   0 parcels
--run-date:   5 parcels

The wall-clock version returns nothing, and would have returned something different in March 2023 β€” it cannot reproduce its own history. A pipeline whose behaviour depends on the wall clock cannot be re-run to reproduce an old result, and cannot be backfilled. Passing the date in costs one argument and buys both.

Notifications and external calls

A local stand-in for the alert webhook counts what arrives. The first version keeps its "already sent" set in memory and keys it on hash(message):

import threading
from http.server import BaseHTTPRequestHandler, HTTPServer
import requests

received = []
class Hook(BaseHTTPRequestHandler):
    def do_POST(self):
        received.append(self.rfile.read(int(self.headers["Content-Length"])))
        self.send_response(204); self.end_headers()
    def log_message(self, *args):
        pass

server = HTTPServer(("127.0.0.1", 0), Hook)
threading.Thread(target=server.serve_forever, daemon=True).start()
HOOK = f"http://127.0.0.1:{server.server_port}/hook"

def notify_once(message: str, run_id: str, sent: set) -> None:
    key = f"{run_id}:{hash(message)}"
    if key in sent:
        return
    requests.post(HOOK, json={"text": message}, timeout=10)
    sent.add(key)

message = "rutland parcels: 21,253 loaded"
for attempt in range(2):          # a retry is a new process, so it starts with an empty set
    notify_once(message, "2026-08-11", set())
print("alerts received:", len(received))

env = {k: v for k, v in os.environ.items() if k != "PYTHONHASHSEED"}   # a pinned seed would hide it
keys = {subprocess.run([sys.executable, "-c", f"print(hash({message!r}))"],
                       capture_output=True, text=True, env=env).stdout.strip() for _ in range(3)}
print("distinct hash() values in three processes:", len(keys))
alerts received: 2
distinct hash() values in three processes: 3

Both halves fail: a set in memory does not survive into the retry, and hash() of a string is salted per process, so even a persisted key would not match. The version that works stores a stable digest on disk:

SENT = Path("logs/sent.txt")

def notify_once(message: str, run_id: str) -> bool:
    """Alerts are side effects too β€” do not re-send them on a retry."""
    key = f"{run_id}:{hashlib.sha256(message.encode()).hexdigest()[:16]}"
    if SENT.exists() and key in SENT.read_text().split():
        return False
    requests.post(HOOK, json={"text": message}, timeout=10)
    SENT.parent.mkdir(parents=True, exist_ok=True)
    with SENT.open("a") as f:
        f.write(key + "\n")
    return True

received.clear()
print([notify_once(message, "2026-08-11") for attempt in range(3)], "received:", len(received))
server.shutdown()
[True, False, False] received: 1

The key is written after the post succeeds, so a crash between the two re-sends one alert rather than losing it β€” for an alert that is the right way round. For an external API that creates something, use an idempotency key if the service supports one β€” that is exactly what payment APIs mean by the term, and the concept transfers.

Verify the property

The test is to run the job twice and compare. Here it is on the load() from the quick answer:

import sqlite3
import pyogrio

def file_hash(path: Path) -> str:
    return hashlib.sha256(Path(path).read_bytes()).hexdigest()

def last_change(path):
    with sqlite3.connect(path) as db:
        return db.execute("SELECT last_change FROM gpkg_contents").fetchone()[0]

first = load(gdf, run_date); hash_one, stamp_one = file_hash(first), last_change(first)
time.sleep(1)
second = load(gdf, run_date)
print("rows equal:", len(gpd.read_file(second)) == len(gdf))
print("bytes equal:", file_hash(second) == hash_one, "|", stamp_one, "->", last_change(second))

pyogrio.set_gdal_config_options({"OGR_CURRENT_DATE": f"{run_date}T00:00:00Z"})
hashes = {file_hash(load(gdf, run_date)) for attempt in range(2)}
print("with OGR_CURRENT_DATE:", len(hashes), "distinct hash")
pyogrio.set_gdal_config_options({"OGR_CURRENT_DATE": None})
rows equal: True
bytes equal: False | 2026-10-10T23:15:40.694Z -> 2026-10-10T23:15:41.927Z
with OGR_CURRENT_DATE: 1 distinct hash

Row count is the first-order check; a byte-identical hash is the strong one, and it only holds if nothing in the output depends on the current time. GeoPackage is itself such an output: GDAL stamps gpkg_contents.last_change with the time of writing, so two otherwise identical runs differ in those bytes. Setting OGR_CURRENT_DATE to the run date makes the stamp a function of the inputs like everything else.

Re-running the same Rutland job until it is safe

The data used on this page

HM Land Registry's INSPIRE Index Polygons for Rutland (21,253 freehold parcels), the ONS boundary of the Oakham North West ward, Geofabrik's Rutland OpenStreetMap shapefiles for the batch in Example 3, and a throwaway PostGIS 3.4 in Docker for the database loads. gdf and run_date are used throughout.

import io
import os
import time
import zipfile
from pathlib import Path

import geopandas as gpd
import pyogrio
import requests
from sqlalchemy import create_engine

pyogrio.set_gdal_config_options({"GML_DOWNLOAD_SCHEMA": "NO"})
for folder in ["data/raw", "data/ref", "data/shp"]:
    Path(folder).mkdir(parents=True, exist_ok=True)

if not Path("data/raw/parcels.gpkg").exists():
    reply = requests.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")
    gpd.read_file("downloads/Land_Registry_Cadastral_Parcels.gml").to_file("data/raw/parcels.gpkg")
if not Path("data/ref/oakham.gpkg").exists():
    reply = requests.get("https://services1.arcgis.com/ESMARspQHYMw9BZ9/arcgis/rest/services/"
                         "Wards_December_2024_Boundaries_UK_BGC/FeatureServer/0/query",
                         params={"where": "WD24NM='Oakham North West'", "outFields": "WD24CD,WD24NM",
                                 "outSR": 4326, "f": "geojson"}, timeout=120)
    reply.raise_for_status()
    gpd.read_file(io.BytesIO(reply.content)).to_file("data/ref/oakham.gpkg")
if not Path("downloads/rutland-shp.zip").exists():
    reply = requests.get("https://download.geofabrik.de/europe/united-kingdom/england/"
                         "rutland-260101-free.shp.zip", timeout=300)
    reply.raise_for_status()
    Path("downloads/rutland-shp.zip").write_bytes(reply.content)
zipfile.ZipFile("downloads/rutland-shp.zip").extractall("data/shp")

os.environ["PGPASSWORD"] = "guide"
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@localhost:55433/gis")

gdf = gpd.read_file("data/raw/parcels.gpkg")
run_date = "2026-08-11"
print(f"{len(gdf):,} parcels, {gdf.crs}; "
      f"{len(list(Path('data/shp').glob('*.shp')))} shapefiles; "
      f"ward: {gpd.read_file('data/ref/oakham.gpkg').WD24NM[0]}")
21,253 parcels, EPSG:27700; 18 shapefiles; ward: Oakham North West

Example 1: an idempotent file pipeline

%%writefile idem_pipeline.py
#!/usr/bin/env python3
from datetime import date
from pathlib import Path
import argparse, hashlib, json
import geopandas as gpd
import pyogrio

STATE = Path("logs/state.json")

def fingerprint(*paths) -> str:
    h = hashlib.sha256()
    for path in sorted(Path(p) for p in paths):
        stat = path.stat()
        h.update(f"{path.name}:{stat.st_size}:{int(stat.st_mtime)}".encode())
    return h.hexdigest()[:16]

def write_atomic(gdf, dest: Path, run_date: date) -> Path:
    tmp = dest.parent / ".partial" / dest.name
    tmp.parent.mkdir(parents=True, exist_ok=True)
    pyogrio.set_gdal_config_options({"OGR_CURRENT_DATE": f"{run_date.isoformat()}T00:00:00Z"})
    try:
        gdf.to_file(tmp, driver="GPKG")
    finally:
        pyogrio.set_gdal_config_options({"OGR_CURRENT_DATE": None})
    tmp.replace(dest)
    return dest

def run(input_path: Path, boundary_path: Path, out_dir: Path,
        run_date: date, force: bool = False) -> dict:
    dest = out_dir / f"parcels_{run_date.isoformat()}.gpkg"
    key = fingerprint(input_path, boundary_path)

    state = json.loads(STATE.read_text()) if STATE.exists() else {}
    if not force and dest.exists() and state.get(str(dest)) == key:
        return {"status": "skipped", "output": str(dest), "reason": "inputs unchanged"}

    gdf = gpd.read_file(input_path)
    boundary = gpd.read_file(boundary_path).to_crs(gdf.crs)
    clipped = gpd.clip(gdf, boundary, keep_geom_type=True)
    clipped = clipped[clipped.geometry.notna() & clipped.geometry.is_valid]

    write_atomic(clipped, dest, run_date)
    state[str(dest)] = key
    STATE.parent.mkdir(parents=True, exist_ok=True)
    STATE.write_text(json.dumps(state, indent=2))
    return {"status": "written", "output": str(dest), "features": len(clipped)}

def main(argv=None):
    ap = argparse.ArgumentParser()
    ap.add_argument("--run-date", type=date.fromisoformat, default=date.today())
    ap.add_argument("--force", action="store_true")
    args = ap.parse_args(argv)
    print(run(Path("data/raw/parcels.gpkg"), Path("data/ref/oakham.gpkg"),
              Path("data/out/pipeline"), args.run_date, args.force))

if __name__ == "__main__":
    main()

Run it ten times from the shell, as a scheduler would:

import ast, collections
runs = [subprocess.run([sys.executable, "idem_pipeline.py", "--run-date", "2026-08-11"],
                       capture_output=True, text=True).stdout.strip() for attempt in range(10)]
print(runs[0])
print(runs[1])
print(collections.Counter(ast.literal_eval(r)["status"] for r in runs))
print(sorted(p.name for p in Path("data/out/pipeline").iterdir()), "|", Path("logs/state.json").read_text().count(".gpkg"), "state entry")
{'status': 'written', 'output': 'data/out/pipeline/parcels_2026-08-11.gpkg', 'features': 1425}
{'status': 'skipped', 'output': 'data/out/pipeline/parcels_2026-08-11.gpkg', 'reason': 'inputs unchanged'}
Counter({'skipped': 9, 'written': 1})
['.partial', 'parcels_2026-08-11.gpkg'] | 1 state entry

One file (beside the empty .partial/ folder its writes pass through), one state entry, nine no-ops. The clip keeps the 1,425 parcels that fall in the Oakham North West ward.

Example 2: a failed load leaves the previous state

The point of the transaction is what happens when the insert fails after the delete. Here the second load is handed a frame with a column the table does not have:

def replace_slice(gdf, table: str, run_date: str) -> dict:
    with engine.begin() as conn:
        exists = conn.execute(text("SELECT to_regclass(:t)"), {"t": table}).scalar()
        deleted = 0
        if exists:
            deleted = conn.execute(
                text(f"DELETE FROM {table} WHERE run_date = :d"), {"d": run_date}).rowcount
        gdf.assign(run_date=run_date).to_postgis(table, conn, if_exists="append", index=False)
    return {"deleted": deleted, "inserted": len(gdf), "run_date": run_date}

this_slice = "run_date = '2026-08-11'"
print(replace_slice(gdf, "parcels_slices", "2026-08-11"))
print(f"slice now: {count('parcels_slices', this_slice):,}")
try:
    replace_slice(gdf.assign(surveyor="unknown"), "parcels_slices", "2026-08-11")
except Exception as exc:
    print("failed:", type(exc).__name__, str(exc).splitlines()[0])
print(f"slice after the failure: {count('parcels_slices', this_slice):,}")
{'deleted': 21253, 'inserted': 21253, 'run_date': '2026-08-11'}
slice now: 21,253
failed: UndefinedColumn column "surveyor" of relation "parcels_slices" does not exist
slice after the failure: 21,253

The DELETE ran β€” and was rolled back with the failed insert, so the table still holds the last good load. Had the delete been committed on its own, the failure would have left the slice empty, which is worse than doubled.

A delete-then-insert whose insert fails, inside one transaction. one transaction, insert failsslice before21,253 rowsDELETE slice21,253 deletedINSERTUndefinedColumnROLLBACKdelete undoneslice after21,253 rowsThe delete only counts if the insert does spatialworkflow.io
Example 2, measured: a failed reload leaves the last good load in place, not an empty slice.

Example 3: a resumable batch that is idempotent per file

def convert_folder(src: Path, out: Path, force=False) -> dict:
    out.mkdir(parents=True, exist_ok=True)
    written = skipped = failed = 0

    for shp in sorted(src.rglob("*.shp")):
        dest = (out / shp.relative_to(src)).with_suffix(".gpkg")
        if dest.exists() and not force and dest.stat().st_mtime >= shp.stat().st_mtime:
            skipped += 1
            continue
        try:
            tmp = dest.parent / ".partial" / dest.name
            tmp.parent.mkdir(parents=True, exist_ok=True)
            gpd.read_file(shp).to_file(tmp, driver="GPKG")
            tmp.replace(dest)
            written += 1
        except Exception as exc:
            print(f"failed {shp.name}: {exc}")
            failed += 1

    return {"written": written, "skipped": skipped, "failed": failed}

for label in ["first run", "second run"]:
    start = time.perf_counter()
    print(f"{label:22}", convert_folder(Path("data/shp"), Path("data/gpkg")),
          f"{time.perf_counter() - start:.1f} s")

zipfile.ZipFile("downloads/rutland-shp.zip").extractall("data/shp")   # the same download, unpacked again
start = time.perf_counter()
print(f"{'after re-extracting':22}", convert_folder(Path("data/shp"), Path("data/gpkg")),
      f"{time.perf_counter() - start:.1f} s")
first run              {'written': 18, 'skipped': 0, 'failed': 0} 0.2 s
second run             {'written': 0, 'skipped': 18, 'failed': 0} 0.0 s
after re-extracting    {'written': 18, 'skipped': 0, 'failed': 0} 0.2 s

Because each file is written atomically and skipped when up to date, an interrupted run can simply be started again β€” no bookkeeping required beyond the filesystem itself. The price of using modification times is visible in the third line: unpacking the same archive again gives every shapefile a new mtime, and the whole folder is converted again. That is wasted work, never a wrong result, which is the right way for a skip test to err.

Example 4: prove it in the test suite

%%writefile test_idem_pipeline.py
import hashlib
from datetime import date
from pathlib import Path

import pytest
from idem_pipeline import run

def digest(path) -> str:
    return hashlib.sha256(Path(path).read_bytes()).hexdigest()

@pytest.fixture
def kwargs(tmp_path):
    return dict(input_path=Path("data/raw/parcels.gpkg"), boundary_path=Path("data/ref/oakham.gpkg"),
                out_dir=tmp_path, run_date=date(2026, 8, 11))

def test_rerun_produces_identical_output(kwargs):
    first = run(**kwargs)
    assert first["status"] == "written"
    first_hash = digest(first["output"])

    second = run(**kwargs)
    assert second["status"] == "skipped"

    third = run(**kwargs, force=True)
    assert digest(third["output"]) == first_hash, "forced re-run changed the bytes"
!{sys.executable} -m pytest -q -p no:cacheprovider test_idem_pipeline.py 2>&1 | tail -2
.                                                                        [100%]
1 passed in 0.59s

The third assertion is the strict one: it fails if anything in the output embeds a timestamp or a random value, which is usually a genuine reproducibility bug worth knowing about. It passes here only because write_atomic pins OGR_CURRENT_DATE; without that line the same test fails on the GeoPackage's own last_change stamp, as the section above measured.

Same result twice as once

Idempotency comes from mathematics β€” an operation is idempotent when applying it twice gives the same result as applying it once β€” and in data engineering it means running a job again leaves the system in the same state, not a doubled one.

A failed run, a retry and convergence to one correct state. what a safe retry looks like1run 1 fails at 02:47partial write discarded2state is unchangedatomic rename never happened3run 2 starts at 09:15same inputs, same paths4output replaced, not appendeddelete-then-insert5one correct resultno clean-up neededRetry, resume, backfill and reproduce are all the same capability spatialworkflow.io
Retry, resume, backfill and reproduce are all the same capability, and they all need this one property.

The reason it matters so much for scheduled work is that failures are normal. A network blip, a locked file, a full disk, a source that published late β€” none of these are exceptional, and all of them mean the job must run again. If re-running is safe, the response is automatic: the scheduler retries, or someone runs it in the morning, and the state converges. If re-running is not safe, every failure requires a human to determine what state the world is in before doing anything, and that human is usually you, at an inconvenient hour.

The property is broken by three things, and only three. Accumulation: appending rows, adding files, incrementing counters. Non-determinism: output that depends on now(), a random seed, or the order of a directory listing. Partial effects: a job that half-wrote a file or committed some rows and not others, leaving a state neither before nor after.

Each has a standard remedy. Accumulation becomes replacement β€” overwrite, delete-then-insert within a key, or upsert. Non-determinism becomes parameterisation β€” take the run date as an argument, seed the generator, sort the file list. Partial effects become atomicity β€” write to a temporary name and rename, or wrap the database work in a transaction.

The payoff extends well beyond retries. A pipeline that is safe to re-run can be backfilled over historical dates, can skip work whose inputs have not changed, can be tested by running it twice, and can reproduce an old result on demand. Those are four different capabilities, and they all fall out of the same discipline β€” which is why idempotency is usually the first property worth engineering into a job that runs without you.

Renames across filesystems and other atomicity gaps

  • Path.replace() is atomic on the same filesystem only: Writing the temporary file into a different mount and renaming across it is a copy, not a rename β€” and Path.replace() refuses it outright with OSError: [Errno 18] Invalid cross-device link.
  • Keep the extension on the temporary file: Writing a GeoPackage to parcels.gpkg.part works, but GDAL warns on every write that the extension should be gpkg. A .partial/ folder beside the output keeps the real name and the real extension, and a *.gpkg glob of the output folder never picks it up.
  • Appending is sometimes correct: Event logs and audit trails are meant to accumulate. Make that a deliberate exception, keyed so duplicates can be detected.
  • Timestamps inside outputs break byte-identity: A "created_at" column is useful and makes hash comparison impossible. Choose which you want.
  • Deterministic ordering matters: sorted() your file lists, and sort before writing, or two runs produce different byte layouts from the same data.
  • Idempotent is not the same as incremental: Replacing the whole output every run is idempotent and possibly wasteful; fingerprinting inputs adds the efficiency.
  • Watch out for external side effects: Emails, tickets and webhook calls are not undone by a rollback. Guard them with a sent-set or an idempotency key.
  • Partial reruns need a slice key: "This run owns run_date = X" is what makes delete-then-insert safe when several runs share a table.

FAQ

What does idempotent mean for a data job?

Running it twice with the same inputs leaves the same state as running it once. No doubled rows, no extra files, no half-written outputs.

How do I make a database load idempotent?

Delete the slice this run owns and insert it again inside one transaction, or use an upsert keyed on a business identifier. Never plain-append without a key.

Why does using datetime.now() break it?

Because the output then depends on when the job ran rather than on its inputs, so a re-run produces a different file name or a different filtered set. Pass the run date in as a parameter.

What is an atomic write and why does it matter?

Writing to a temporary file and renaming it into place, so the destination never exists in a partial state. Without it, a crash leaves a file at the real name that is not the dataset: a GeoPackage killed mid-write here could not be opened read-only, and recovered to an empty 66 kB file.

Is skipping unchanged work part of idempotency?

Strictly it is an optimisation on top: idempotency says a re-run is safe, fingerprinting inputs makes it cheap. They pair naturally.

How do I test for it?

Run the pipeline twice in a test and assert the output is unchanged β€” row count at minimum, byte-identical hash if nothing embeds a timestamp. A GeoPackage does (gpkg_contents.last_change) unless OGR_CURRENT_DATE is set.

Are there cases where appending is right?

Yes β€” audit logs, event streams and run ledgers are meant to grow. Make it explicit, and key the entries so a repeated run can be detected rather than double-counted.