Incremental Processing: How a Batch Job Knows What Changed

On this page

Problem statement

A folder of files arrives every night. On most nights almost none of them differ from yesterday's, and a job that reprocesses everything spends its window redoing work that was already correct. The outputs it rewrites are identical to the ones they replace, their modification times all jump to tonight, and anything downstream watching for changes sees every file as changed.

The companion how-to runs exactly this on HM Land Registry's Leeds titles split into 33 ward files: 3.8 seconds to process them all, 0.2 seconds for a night when nothing changed, 0.3 seconds for a night when one ward was edited β€” see how to process only the files that changed. Scale the folder up to hundreds of files and gigabytes, and the same ratio is the difference between a job that fits overnight and one that does not.

The obvious fix β€” skip files whose output already exists β€” is wrong in a way that only shows up later: it never reprocesses a file whose content changed but whose output still exists. The job silently stops updating.

Getting this right means answering one question precisely: what does "changed" mean, and what evidence proves it?

Quick answer

Four ways to decide, in increasing order of reliability and cost:

Signal Detects Misses Cost
output exists never-processed items any content change free
mtime newer than output edits and re-deliveries touched-but-unchanged files (false positive) one stat()
content hash genuine content changes only nothing reads every byte
manifest changes to input or to the code/config nothing a state file
import hashlib, json
from pathlib import Path

def content_hash(path, chunk=1 << 20):
    h = hashlib.blake2b(digest_size=16)
    with open(path, "rb") as f:
        while block := f.read(chunk):
            h.update(block)
    return h.hexdigest()

def needs_processing(src: Path, state: dict, recipe: str) -> bool:
    prev = state.get(str(src))
    if prev is None:
        return True                              # never seen
    if prev["recipe"] != recipe:
        return True                              # the code or config changed
    if prev["size"] != src.stat().st_size:
        return True                              # cheap pre-filter
    return prev["hash"] != content_hash(src)     # the authority

On the Leeds job, the third night's discovery reported 33 files Β· 1 to process {'content': 1} β€” one ward edited, found by its hash, and 32 skipped.

The recipe field is the part people leave out and regret. If the transformation changes, every output is stale regardless of whether any input moved β€” and a job that only watches inputs will never notice.

The four signals

Four change-detection signals against what each detects and misses. only the bottom row notices a code changeinput editedcode changedcostoutput existsβœ•missedβœ•missedβœ“freemtimeβœ“caughtβœ•missedβœ“a stat()content hashβœ“caughtβœ•missed!read all bytesmanifestβœ“caughtβœ“caughtβœ“a state fileA stale output reports success and serves last month spatialworkflow.io
Only the bottom row notices that the code changed.

Existence, mtime, content hash, manifest

Decision diagram from how inputs arrive to the right change signal. how does the data arrive?Can an input change after it lands?never β€” dated filesOutput existsdocument the assumptionrarely, locallymtime + sizeerrs toward extra workyes, in placeHash + recipenever missesAdd a recipe id to every one of these, or code changes invalidate nothing spatialworkflow.io
How the data arrives decides which signal is honest.

Signal 1: output exists β€” the one that quietly stops working

def needs_processing(src, dst):
    return not output_for(src, dst).exists()

This is correct for exactly one situation: inputs are immutable. Archive tiles, dated deliveries in dated folders, anything with a content-addressed name. If 2024-03-01_parcels.gpkg never changes after it lands, its output never needs rebuilding.

It is wrong the moment a supplier overwrites a file in place. The output exists, so the file is skipped, and the job reports success while serving data from March forever. This failure is completely silent β€” the counts look normal, no error is raised, and the only symptom is that a number downstream stops moving.

If you use this signal, be explicit about the assumption:

# Inputs are immutable: the supplier writes dated files and never edits them.
# If that stops being true, this check must change to a content hash.
def needs_processing(src, dst):
    return not output_for(src, dst).exists()

Signal 2: mtime β€” cheap, and a little bit of a liar

def needs_processing(src, dst):
    out = output_for(src, dst)
    return not out.exists() or src.stat().st_mtime > out.stat().st_mtime

Almost free, and catches genuine edits. Its failure modes are all in the direction of doing too much work rather than too little, which is the safe direction:

  • rsync and cp update mtime even when the content is identical, so a re-delivery of unchanged data reprocesses everything.
  • touch marks a file changed without changing a byte.
  • Clock skew between machines makes a file look older than an output produced from it.
  • Filesystem granularity is one second on some systems, so a file rewritten within the same second as its output looks unchanged β€” the one direction where mtime does too little.

Guard against the last one by comparing with a margin:

return src.stat().st_mtime > out.stat().st_mtime + 1

Signal 3: content hash β€” the authority

def content_hash(path, chunk=1 << 20):
    h = hashlib.blake2b(digest_size=16)      # faster than sha256, plenty for this
    with open(path, "rb") as f:
        while block := f.read(chunk):
            h.update(block)
    return h.hexdigest()

A hash answers the real question: is this file's content the same as the content we processed? It has no false positives and no false negatives.

The cost is reading every byte. On this site's build machine, hashing a 926 MB GeoJSON already in the page cache ran at 0.8 GB/s with BLAKE2b and 1.7 GB/s with SHA-256; from disk, and far more over a network share, it is slower. Tens of gigabytes is still tens of seconds locally β€” nothing next to reprocessing them.

Use size as a free pre-filter, because a different size guarantees different content:

if prev["size"] != src.stat().st_size:
    return True                     # certainly changed, no need to hash
return prev["hash"] != content_hash(src)

For very large files where even reading is too slow, hash a sample β€” first megabyte, last megabyte, size β€” and accept a small chance of missing a change:

def cheap_fingerprint(path, edge=1 << 20):
    size = path.stat().st_size
    h = hashlib.blake2b(digest_size=16)
    h.update(str(size).encode())
    with open(path, "rb") as f:
        h.update(f.read(edge))
        if size > 2 * edge:
            f.seek(-edge, 2)
            h.update(f.read(edge))
    return h.hexdigest()

Be honest in the name and the comment: this is a fingerprint, not a hash, and an edit in the middle of a large file will be missed.

Signal 4: the manifest β€” inputs and the recipe

A manifest record that captures the input and the code that produced the output. the field most manifests leave out{"src":Β Β Β Β "raw/wards.gpkg","sha256": "9f2c1b…", "size": 2411984,"recipe": "clean_wards@3","out":Β Β Β Β "clean/wards.gpkg"}β‘ β‘‘β‘ The only signal that survives a supplier rewriting a file in place, andthe only one that ignores a touch that changed nothing.β‘‘Bump it whenever the code changes. Without it the job keeps serving outputbuilt by an older version of your own function.A manifest that records inputs alone cannot notice that you changed the processing spatialworkflow.io
The hash answers "did the input change"; the recipe id answers "did I change".

The complete version records everything the output depends on:

def recipe_id(config: dict, version: str) -> str:
    """Anything that changes the output, other than the input file."""
    payload = json.dumps({"version": version, "config": config}, sort_keys=True)
    return hashlib.blake2b(payload.encode(), digest_size=8).hexdigest()
state = {
  "data/orkney.gpkg": {
      "hash": "9f2c…", "size": 8412193, "mtime": 1723190400.0,
      "recipe": "a1b2c3d4",                 # ← code + config at the time
      "output": "out/orkney.gpkg",
      "processed_at": "2026-08-15T02:03:11Z",
      "rows": 4102
  },
  ...
}

Now bumping VERSION or changing the target CRS in the config invalidates everything, exactly as it should. Without it, a change to the transformation produces a job that skips every file and reports success β€” the same silent failure as signal 1, arriving from the other direction.

What "changed" means for a database source

Files have hashes; tables do not, at least not cheaply. The equivalents:

-- an updated_at column, if the source maintains one honestly
SELECT * FROM parcels WHERE updated_at > :last_run;

-- a monotonically increasing id, for append-only tables
SELECT * FROM events WHERE id > :last_id;

-- xmin, the Postgres row version β€” works without cooperation from the schema
SELECT * FROM parcels WHERE xmin::text::bigint > :last_xmin;

The first is only as good as whoever maintains the column β€” a bulk update that forgets to touch it makes rows invisible to the job forever. The second is reliable and only works for inserts. The third needs care around transaction id wraparound but requires nothing of the schema.

Store the state where it survives

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

def load_state():
    return json.loads(STATE.read_text()) if STATE.exists() else {}

def save_state(state):
    STATE.parent.mkdir(parents=True, exist_ok=True)
    tmp = STATE.with_suffix(".tmp")
    tmp.write_text(json.dumps(state, indent=2, sort_keys=True))
    tmp.replace(STATE)                  # atomic β€” a crash never leaves half a manifest

The write-to-temp-then-rename is not optional. A manifest truncated by a crash is worse than no manifest: the job forgets what it did and reprocesses everything, or worse, forgets only some entries.

Update the state after the output is safely written, never before:

for item in changed:
    result = apply_one(item)            # writes the output
    if result.status == "ok":
        state[str(item)] = fingerprint(item, recipe)     # only now
save_state(state)

Recording first and processing second means a crash leaves the manifest claiming work that never happened β€” and that file is then skipped forever.

Incremental discovery with a safety valve

Example 1: the complete incremental discovery

import hashlib, json
from dataclasses import dataclass
from pathlib import Path

@dataclass
class Change:
    path: Path
    reason: str          # "new" | "content" | "recipe" | "size"

def plan(src: Path, state: dict, recipe: str, pattern="*.gpkg") -> tuple[list, dict]:
    changed, unchanged = [], 0
    for path in sorted(src.rglob(pattern)):
        prev = state.get(str(path))
        if prev is None:
            changed.append(Change(path, "new"))
        elif prev.get("recipe") != recipe:
            changed.append(Change(path, "recipe"))
        elif prev.get("size") != path.stat().st_size:
            changed.append(Change(path, "size"))
        elif prev.get("hash") != content_hash(path):
            changed.append(Change(path, "content"))
        else:
            unchanged += 1
    return changed, {"unchanged": unchanged, "changed": len(changed)}
changed, summary = plan(Path("data"), load_state(), recipe_id(CONFIG, VERSION))
from collections import Counter
print(summary, Counter(c.reason for c in changed))
# {'unchanged': 896, 'changed': 4} Counter({'content': 3, 'new': 1})

Reporting the reason is what makes this debuggable. "all changed, reason=recipe" says somebody bumped the version; "all changed, reason=content" says the supplier re-exported everything; "all changed, reason=new" says the manifest was lost.

Example 2: the safety valve every incremental job needs

def guard_full_rebuild(changed, total, *, threshold=0.5, force=False):
    """A sudden full rebuild is usually a bug in change detection, not a real change."""
    if force or not total:
        return changed
    share = len(changed) / total
    if share > threshold:
        raise SystemExit(
            f"{len(changed):,}/{total:,} items ({share:.0%}) look changed. "
            f"That is unusual β€” check the manifest and the recipe id, "
            f"then re-run with --force if it is genuinely a full rebuild."
        )
    return changed

The failure this prevents is expensive and silent: a corrupted manifest, a changed hash function, or a recipe id that includes a timestamp makes every item look changed, and the job cheerfully spends the whole night doing what it was built to avoid. Failing loudly costs a manual re-run; not failing costs the overnight window.

Example 3: making the recipe honest

import inspect

def recipe_id(config: dict, *functions) -> str:
    """Hash the config AND the source of the transformation functions."""
    h = hashlib.blake2b(digest_size=8)
    h.update(json.dumps(config, sort_keys=True, default=str).encode())
    for fn in functions:
        h.update(inspect.getsource(fn).encode())
    return h.hexdigest()

RECIPE = recipe_id(CONFIG, transform_parcels, clean_attributes)

Hashing the function source means editing the transformation automatically invalidates every output, with no discipline required. It has limits β€” it does not see changes in functions those two call, or in library versions β€” so pin the library versions in the config and treat it as a strong default rather than a proof.

Incremental processing is a cache

How an output-exists check and a hash-plus-recipe check respond to two kinds of change. both silent failures look like successOutput exists onlyβœ•supplier edits a file β†’ skippedβœ•you change the CRS β†’ skippedβœ•job reports every file skippedβœ•exit code 0βœ•stale data, foreverHash + recipe idβœ“supplier edits a file β†’ reprocessedβœ“you change the CRS β†’ all reprocessedβœ“job reports the reasonβœ“exit code 0, correctlyβœ“output always currentStale is much worse than slow spatialworkflow.io
Both silent failures produce the same symptom: a successful run that changed nothing.

Incremental processing is a cache, and it inherits every hard problem caches have. The output is a cached value; the input file and the transformation are its key; and the entire question is whether the key is complete.

An incomplete key produces stale results with no error. That is the failure mode running through this whole page, and it arrives in two ways. Watching only the output's existence misses input edits. Watching only the input misses code and config changes. Both produce a job that runs, reports success, and serves data from weeks ago β€” the worst kind of failure, because every signal says fine.

The mirror-image failure is over-invalidation: a key that includes something irrelevant, so everything looks changed and the job does full work every night while claiming to be incremental. The usual cause is a timestamp or an absolute path leaking into the recipe id. The guard in example 2 exists because this failure is otherwise invisible β€” the job still produces correct output, just slowly, and nobody investigates a job that works.

Between the two, stale is much worse than slow, which is why the recommended default is a content hash plus a recipe id: it never misses a change, and the cost is bounded by read bandwidth.

The last piece is ordering. State must be written after the output, and atomically. A manifest updated before processing records work that may never happen; a manifest written in place can be truncated by a crash. Temp-file-plus-rename makes the update atomic on the same filesystem, so the manifest is always either the old complete one or the new complete one β€” which is the same idempotency argument that applies to the outputs themselves.

mtime granularity, network filesystems and deleted inputs

  • st_mtime granularity is 1 second on some filesystems and 1 nanosecond on others. Compare with a margin.
  • Network filesystems have unreliable mtimes and clock skew between client and server. Hash instead.
  • Pick the hash for speed on your hardware. On CPUs with SHA extensions β€” most x86 chips from the last few years β€” hashlib.sha256 beat blake2b by two to one on this site's machine (1.7 against 0.8 GB/s). Either is ample for change detection, which is not cryptography.
  • A hash of a GeoPackage changes when SQLite reorganises pages, even with identical data. Hashing the data rather than the file requires reading it as a layer, which is far more expensive.
  • Deleted inputs need handling. An entry in the manifest whose file is gone means the output should probably be removed too.
  • The manifest grows forever. Prune entries whose source no longer exists, or it slowly becomes the largest file in the project.
  • Do not put the manifest in the output directory if anything downstream syncs that directory β€” it will be treated as a deliverable.
  • Parallel workers must not write the manifest concurrently. Collect results and write once in the parent.

FAQ

Is checking whether the output exists good enough?

Only if inputs are genuinely immutable β€” dated files that are never overwritten. If a supplier can edit a file in place, this check silently stops updating and never reports a problem.

mtime or content hash?

mtime when inputs are local and re-deliveries are rare; a hash when correctness matters more than a few seconds of read time. mtime's errors are all in the direction of extra work, except when the filesystem's resolution is coarse.

Why include a recipe id?

Because the output depends on the code and config as well as the input. Without it, changing the target CRS produces a run that skips every file and reports success.

What if the manifest is lost?

Everything looks new and the job does a full rebuild β€” which is correct but expensive. The guard in example 2 turns that into a visible prompt rather than a silent full night's work.

How do I do this for a database table?

Use an updated_at column if one is honestly maintained, a monotonic id for append-only data, or Postgres xmin if the schema offers nothing.

Should the manifest live in version control?

No β€” it is run state, not source. Keep it beside the outputs or in a state directory, and make sure it is backed up with them.

How do I test change detection?

Process, assert everything ran; re-run, assert nothing ran; touch a file's content, assert exactly one item ran; bump the recipe, assert everything ran.