Provenance and lineage explained for spatial workflows
Problem statement
The number in the report is 4,812. Six months later somebody asks where it came from, and the honest answer is: from a notebook that has been edited since, run against a file that has been replaced, using a library that has been upgraded twice. The map is still on the wall, and nobody can reproduce it.
Lineage is the ordered record of what went into a result and what was done to it. Provenance is the broader question it answers: can this output be traced, explained and reproduced? Every other metadata field can be recomputed from the data; lineage cannot, because it describes events that have already happened.
This guide sets out what a lineage record has to contain to be worth keeping, how much detail is enough, and why the checksum you record for a spatial file needs care.
Quick answer
Record, for every output: the inputs with their versions and content hashes, the operations with their parameters, the code version, the environment, and when it ran.
run = {
"output": "flood_zones_2025.gpkg",
"inputs": [
{"name": "ea_lidar_dtm_2024", "version": "2024-06", "sha256": "9f3cโฆ", "licence": "OGL-UK-3.0"},
{"name": "ea_fluvial_model", "version": "v7", "sha256": "41abโฆ", "licence": "internal"},
],
"steps": [
{"op": "breach_depressions", "lib": "pysheds 0.4", "params": {}},
{"op": "flowdir", "lib": "pysheds 0.4", "params": {"routing": "d8"}},
{"op": "threshold", "params": {"return_period_years": 200}},
{"op": "polygonise", "lib": "rasterio 1.5.1", "params": {"min_area_m2": 100}},
],
"code": {"repo": "flood-pipeline", "commit": "3f9a1c2", "dirty": False},
"environment": {"python": "3.14.0", "geopandas": "1.1.4", "gdal": "3.11.0"},
"run_at": "2026-01-20T09:14:33Z",
"output_sha256": "c2d1โฆ",
}
Everything in that record is either machine-readable at run time or a constant. None of it depends on anyone remembering anything.
Step-by-step solution
1. Decide what an "output" is
Lineage attaches to an artefact. If your pipeline writes six files, it writes six lineage records โ or one record listing six outputs. What it must not do is describe "the pipeline" in general, because the question people ask is always about a specific file.
2. Identify inputs by content, not by path
/data/current/dtm.tif identifies nothing: the file at that path changes. Record a name, a version or date, and a content hash. The hash is what turns "we used the 2024 DTM" into a checkable claim.
3. Hash the content, not the container
A byte checksum of a GeoPackage is not reproducible: writing the same GeoDataFrame twice produces different bytes, because the container records its own timestamps. In a direct test, two writes of an identical 120-feature layer gave different SHA-256 digests for GeoPackage, GeoJSON and FlatGeobuf, and identical digests only for shapefile and Parquet. For inputs you did not create, a byte hash of the file as downloaded is fine and is the right thing; for outputs you create, hash the canonicalised data. How to checksum spatial datasets so you can prove they match has the method.
4. Record parameters that change the answer, not every argument
A parameter belongs in the lineage if a different value would produce a different output that somebody might question: the routing algorithm, the threshold, the resampling method, the CRS, the simplification tolerance, the random seed. Buffer sizes, yes. Chunk sizes and thread counts, no โ they change speed, not results.
5. Pin the code and the environment
A commit hash and a dirty flag, plus the versions of the libraries that do the arithmetic. GDAL, PROJ, GEOS and the interpolation library all change results between versions; recording them is what makes "it was different last year" a diagnosable statement.
6. Write the record at the same moment as the data
A lineage record produced by a separate step can be written for a file that was never produced. Write it in the same function that writes the output, from the same variables.
7. Keep it with the artefact and in the log
Beside the file so it travels; in a run log so you can ask questions across runs, such as which outputs used a source you have just discovered was wrong.
8. Make it queryable
The payoff comes when someone reports a problem with an input. A lineage store lets you answer "which published outputs used this file?" in a query instead of a week.
Code examples
Example 1 โ capture the environment and the code version
import subprocess, sys, importlib.metadata as md, datetime
def environment():
def version(pkg):
try:
return md.version(pkg)
except md.PackageNotFoundError:
return None
try:
commit = subprocess.check_output(["git", "rev-parse", "--short", "HEAD"], text=True).strip()
dirty = bool(subprocess.check_output(["git", "status", "--porcelain"], text=True).strip())
except Exception:
commit, dirty = None, None
return {
"python": sys.version.split()[0],
"packages": {p: version(p) for p in
("geopandas", "shapely", "pyproj", "rasterio", "pandas", "numpy")},
"git": {"commit": commit, "dirty": dirty},
"run_at": datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds"),
}
Example 2 โ a step recorder that cannot be forgotten
import functools, json, pathlib
class Lineage:
def __init__(self, output):
self.record = {"output": str(output), "inputs": [], "steps": [],
"environment": environment()}
def input(self, name, path, version=None, licence=None):
self.record["inputs"].append({
"name": name, "path": str(path), "version": version, "licence": licence,
"sha256": sha256_of(path),
})
def step(self, op, **params):
def wrap(fn):
@functools.wraps(fn)
def inner(*a, **kw):
self.record["steps"].append({"op": op, "params": params})
return fn(*a, **kw)
return inner
return wrap
def write(self, path=None):
path = pathlib.Path(path or (self.record["output"] + ".lineage.json"))
path.write_text(json.dumps(self.record, indent=2))
return path
lin = Lineage("flood_zones_2025.gpkg")
lin.input("ea_lidar_dtm", "dtm_2024.tif", version="2024-06", licence="OGL-UK-3.0")
@lin.step("polygonise", min_area_m2=100, simplify_m=1.0)
def polygonise(raster): ...
Attaching the record to the function that does the work is what stops the two drifting apart.
Example 3 โ ask which outputs used a bad input
import json, pathlib
def outputs_using(source_name, store="lineage/"):
hits = []
for f in pathlib.Path(store).glob("*.lineage.json"):
rec = json.loads(f.read_text())
if any(i["name"] == source_name for i in rec["inputs"]):
hits.append({"output": rec["output"], "run_at": rec["environment"]["run_at"]})
return sorted(hits, key=lambda h: h["run_at"], reverse=True)
for hit in outputs_using("ea_lidar_dtm"):
print(hit)
This query is the reason to keep lineage in a directory rather than only beside each file.
Explanation
Why lineage cannot be reconstructed
Extent, CRS, schema and feature counts are properties of the data and can be recomputed at any time. Lineage describes events: which file existed at that path on that day, which version of GEOS was installed, what the threshold was before somebody changed it. Once those have passed, nothing in the data records them.
Why versions of libraries belong in the record
Geometry operations are not stable across versions. GEOS has changed the behaviour of buffer, simplify and overlay noding between releases; PROJ has changed default transformation pipelines as new grids shipped; interpolation libraries change their defaults. A result that differs from last year's is usually explained by one of those, and only if they were recorded.
Why the input hash matters more than the input path
The commonest cause of an unreproducible result is that the input changed underneath a stable path. A content hash converts "we used the current extract" into a statement that can be tested against the file you still have, and it is what lets you distinguish "the data changed" from "the code changed".
Why the detail level is a decision
Full provenance โ every function call, every intermediate โ is expensive to capture and almost never read. The useful level is the one that answers the questions you get: which sources, which versions, which parameters, which code. Record that reliably rather than recording everything unreliably.
Edge cases or notes
- A dirty working tree invalidates the commit hash. Record the flag.
- Random seeds are parameters. Any stochastic step needs its seed in the record.
- Manual steps must be recorded as manual. "Edited in QGIS by hand" is lineage.
- Downloads need the date and the URL. Remote sources change without notice.
- Record failures too. A run that produced nothing still explains a gap in a series.
- Do not put credentials in the record. Record the endpoint, not the token.
- Lineage grows. Rotate or index it; a directory of a million JSON files is not queryable.
- Sign it if it matters. For regulated work, a signed record is worth the extra step.
Internal links
- How to record lineage automatically in a pipeline โ the implementation
- How to checksum spatial datasets so you can prove they match โ hashing content rather than bytes
- Dataset versioning explained โ naming the versions lineage refers to
- You cannot tell which data version produced a map โ the failure this prevents
- Spatial metadata explained: what a dataset must tell you โ where lineage sits among the other fields
- How to record run metadata and lineage in a GIS pipeline โ the pipeline-side pattern
- Reproducible GIS environments explained โ pinning what the record names
- How to build a reproducible GIS workflow in Python โ the wider practice
FAQ
What is the difference between provenance and lineage?
Lineage is the record of inputs, operations and versions. Provenance is the property that record gives you: the ability to trace, explain and reproduce an output.
How much detail should a lineage record have?
Enough to answer the questions you get asked: which sources and versions, which parameters changed the answer, which code and library versions, and when it ran. Full call-level provenance is rarely read.
Should I hash my input files?
Yes, as downloaded. That is what turns "we used the 2024 extract" into a checkable claim when the file at that path has since changed.
Why can I not just hash my outputs the same way?
Because several spatial formats are not byte-reproducible. Writing the same layer twice produced different SHA-256 digests for GeoPackage, GeoJSON and FlatGeobuf in a direct test; only shapefile and Parquet were identical.
Do library versions really change the result?
Yes. GEOS, PROJ and GDAL have all changed the behaviour of common operations between releases, and an unexplained difference between two runs is usually one of them.
Where should the lineage record be stored?
Beside the artefact so it travels with it, and in a central store so you can query across runs โ for example to find every output that used a source you have just found to be wrong.