Recording OSM Data Provenance in a Pipeline Jump to heading
Provenance is the licence obligation everybody knows about and the debugging tool nobody expects. It is also the thing that is nearly free to add on day one and a migration project to add on day eight hundred.
Prerequisites Jump to heading
Conceptual minimum Jump to heading
A provenance record answers one question: what exactly produced this? Answering it completely needs four facts.
The source identity. Not “Geofabrik” but which file, with the checksum actually consumed. A name without a digest identifies a moving target.
The data’s own timestamp. The PBF header’s replication timestamp and sequence number describe the state of the map the file captures, independently of when it was downloaded or processed.
The processing version. Which code produced the output. A result derived by a version of the pipeline with a known bug is distinguishable from one that is not, but only if the version was recorded.
The run identity. A stable identifier for this execution, so several outputs from one run can be recognised as siblings.
Together these form a small record that every output references. The record is stored once; outputs carry the identifier.
Runnable solution Jump to heading
from __future__ import annotations
import hashlib
import json
import logging
import subprocess
import uuid
from dataclasses import dataclass, asdict, replace
from datetime import datetime, timezone
from pathlib import Path
import osmium
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
logger = logging.getLogger("osm.provenance")
@dataclass(frozen=True)
class Provenance:
run_id: str
source_path: str
source_digest: str
data_timestamp: str | None # what the map looked like
sequence_number: int | None # replication state of the source
code_version: str
started_at: str
stages: tuple[str, ...] = ()
@property
def provenance_id(self) -> str:
"""Stable hash over the facts, so identical inputs give one identifier."""
payload = json.dumps({
"source_digest": self.source_digest,
"data_timestamp": self.data_timestamp,
"sequence_number": self.sequence_number,
"code_version": self.code_version,
"stages": list(self.stages),
}, sort_keys=True)
return hashlib.sha256(payload.encode()).hexdigest()[:16]
def then(self, stage: str) -> "Provenance":
"""Return a new record extended by one transformation stage."""
return replace(self, stages=self.stages + (stage,))
def code_version() -> str:
try:
out = subprocess.run(["git", "rev-parse", "--short", "HEAD"],
capture_output=True, text=True, check=True)
dirty = subprocess.run(["git", "status", "--porcelain"],
capture_output=True, text=True, check=True)
# A dirty tree means the recorded revision does not describe the code.
return out.stdout.strip() + ("-dirty" if dirty.stdout.strip() else "")
except (OSError, subprocess.CalledProcessError):
logger.warning("no code version available; provenance will be weaker")
return "unknown"
def digest_of(path: Path, chunk: int = 1 << 20) -> str:
hasher = hashlib.sha256()
with path.open("rb") as fh:
while block := fh.read(chunk):
hasher.update(block)
return hasher.hexdigest()
def header_facts(path: Path) -> tuple[str | None, int | None]:
reader = osmium.io.Reader(str(path))
try:
header = reader.header()
stamp = header.get("osmosis_replication_timestamp") or None
seq = header.get("osmosis_replication_sequence_number") or None
finally:
reader.close()
return stamp, int(seq) if seq else None
def ingest(path: Path) -> Provenance:
"""Capture provenance at the moment the input is first read."""
stamp, seq = header_facts(path)
if stamp is None:
logger.warning("%s has no replication timestamp; freshness is unknowable",
path.name)
prov = Provenance(
run_id=str(uuid.uuid4()),
source_path=str(path),
source_digest=digest_of(path),
data_timestamp=stamp,
sequence_number=seq,
code_version=code_version(),
started_at=datetime.now(timezone.utc).isoformat(timespec="seconds"),
)
logger.info("run %s reads %s (data of %s, code %s) -> provenance %s",
prov.run_id[:8], path.name, stamp, prov.code_version,
prov.provenance_id)
return prov
def write_record(prov: Provenance, directory: Path) -> Path:
directory.mkdir(parents=True, exist_ok=True)
path = directory / f"{prov.provenance_id}.json"
path.write_text(json.dumps(asdict(prov), indent=2), encoding="utf-8")
return path
if __name__ == "__main__":
prov = ingest(Path("/data/poland-latest.osm.pbf"))
prov = prov.then("tags-filter:highway").then("export:geoparquet")
logger.info("after %d stage(s), provenance id is %s",
len(prov.stages), prov.provenance_id)
write_record(prov, Path("_provenance"))
Step-by-step walkthrough Jump to heading
- Capture at ingestion, not at output. The facts are available when the file is opened and become guesswork afterwards.
- Hash the file you actually read. A published checksum proves the download was intact; hashing the local file proves you are describing the bytes on disk right now.
- Read the header’s replication fields. These describe the map, not the file transfer, and they are what makes a result comparable against another run months later.
- Warn when the timestamp is missing. A file without one cannot support any freshness claim, and knowing that up front is better than discovering it during an investigation.
- Record a dirty working tree. A revision identifier from a modified checkout does not describe the code that ran, and the suffix says so honestly.
- Extend rather than mutate through stages. Each transformation returns a new record with one more stage appended, so the provenance identifier changes when the processing does.
- Derive the identifier from the facts. A content hash means two runs over identical inputs with identical code produce the same identifier, which is what makes outputs comparable.
- Store the record once, reference it everywhere. Outputs carry a sixteen-character identifier; the full record lives in one place.
Verification Jump to heading
- Identical inputs give identical identifiers. Run twice with no changes; the provenance identifier must match.
- A code change changes the identifier. Commit a change and re-run; it must differ.
- A dirty tree is visible. Modify a file without committing and confirm the recorded version carries the dirty marker.
- Every output references a record. Query outputs for a null provenance identifier; the count must be zero.
- The record resolves. Pick an identifier from an output and confirm the stored record for it exists and describes a real file.
Common errors and fixes Jump to heading
| Symptom | Root cause | One-line fix |
|---|---|---|
| Cannot say what a result came from | Provenance captured at output, not input | Capture the moment the file is opened |
| Two runs indistinguishable | Identifier not derived from the facts | Hash source digest, timestamp and code version together |
| Version recorded but wrong | Dirty working tree not detected | Append a marker when the checkout has uncommitted changes |
| Freshness claims unsupportable | Header timestamp never read | Read the replication fields and warn when absent |
| Provenance lost after a transformation | Record not propagated through stages | Extend the record per stage and carry it forward |
| Every row stores the full record | Record copied instead of referenced | Store once, reference by identifier |
| Retrofit is a large project | Provenance added late | Capture from the first version; it is one column |
Specification reference Jump to heading
A PBF file’s
OSMHeadermay carryosmosis_replication_timestamp,osmosis_replication_sequence_numberandosmosis_replication_base_url, describing the replication state the file was produced from. These fields travel with the file through copies and archives, unlike filesystem metadata. See the PBF format documentation for the header fields and Replication Sequence Numbers & State for what the sequence number means.
Frequently Asked Questions Jump to heading
Why record the header timestamp as well as the download date?
Because they answer different questions. The download date says when you fetched the file; the header timestamp says what state of the map the file describes. A file downloaded today can describe a map from last week if a mirror is stale, and only the header field reveals that. It also travels with the file through copies and archives, so a result derived from an archived extract three years ago is still explainable.
Should the provenance identifier be a hash or a sequence?
A hash over the facts, because it makes identical inputs produce identical identifiers. That in turn means two outputs with the same identifier are provably derived from the same bytes by the same code, which a sequence number cannot tell you. It also removes the need for a central counter, which matters when several machines run stages independently.
How do I carry provenance through a transformation?
By extending the record rather than replacing it: each stage appends its name and returns a new record, so the identifier changes when the processing does and the lineage is readable from the record itself. That way an output’s identifier describes not just which extract it came from but which sequence of transformations produced it, which is what makes two similar-looking outputs distinguishable.
Is one column per row too expensive?
A sixteen-character identifier is negligible next to geometry, and it is one column rather than a copy of the record. The alternative — reconstructing which run produced which rows, after the fact — is not a cost comparison at all, because it is usually impossible. The genuinely expensive option is adding provenance to a pipeline that has been running without it, which means every historical output stays unexplainable.
Related Jump to heading
- OSM Licensing & ODbL Compliance — the parent topic and the obligation provenance satisfies.
- Automating ODbL Attribution in Derived Products — rendering the credit from this record.
- Extracting Metadata from OSM Planet Files — reading the header fields this records.
- Pinning a Reproducible OSM Snapshot by Sequence Number — using the sequence number to recreate an input.
- Mirroring OSM Downloads Behind a Local Cache — content-addressed inputs that make this record resolvable.
Up one level: OSM Licensing & ODbL Compliance.