Last week I changed one line in a module that decides which rows make it into a dataset. A threshold, 10 to 5.
Nothing broke. The tests passed — they test the function, and the function was correct. The CSV in _output/ from the previous run kept sitting there. Three analysis scripts kept reading it. And a write-up I had already finished quoted a number computed from it.
That number was now wrong. Nothing in my project could have told me.
Why nothing catches this
Run through the tools you'd expect to help:
- Your test suite tests the code. The code is fine. The problem is a file that was produced by an older version of that code.
- Make / Snakemake would catch it — if you had written the whole pipeline as a Makefile with correct dependencies, and if timestamps happened to be reliable. Most analysis code isn't written that way. It grows as a pile of scripts.
-
dbt solves this beautifully for tables in a warehouse. It has nothing to say about
_output/table.csvwritten bybuild_table.py. - Great Expectations / pandera check whether the numbers are plausible. Stale numbers are usually perfectly plausible. That's what makes them dangerous.
-
Git happily shows you that
rules.pychanged. It has no idea thattable.csvwas built before that change.
There's a gap here, and it has a shape: nobody owns the edge from "code I changed" to "files that code produced".
Make the read path the checkpoint
The thing I settled on is small. Instead of reading an artifact directly:
df = pd.read_csv("_output/table.csv") # no idea if this is current
you go through a function that can refuse:
import stalegate
df = pd.read_csv(stalegate.path("table.csv")) # raises if the code moved
and on the write side, you stamp:
result.to_csv(out, index=False)
stalegate.stamp("table.csv")
Now the failure looks like this instead of like nothing:
StaleArtifact: refusing to read 'table.csv' - it is code-drift
rules.py: THRESHOLD
Regenerate it:
python build_table.py
The important design choice: there is exactly one way to get an artifact. No second, ungated accessor. The moment you offer two ways to do the same thing, half your call sites will use the one that skips the check.
Timestamps answer the wrong question
The obvious implementation is "compare mtimes". I tried it and it's miserable, for two reasons.
One: mtime is not stable. A fresh git clone, a CI runner, a different machine — every timestamp changes. Everything reports stale. A tool that cries wolf after every checkout is a tool people learn to ignore. That's the most common way a check dies: not by being wrong, but by being annoying.
Two: file granularity is too coarse. You edit a docstring in a 600-line module and it invalidates every artifact downstream. Again: annoying, ignored, dead.
So instead it parses your source with ast and hashes each top-level definition on its own, with docstrings stripped:
tree = ast.parse(source)
for node in tree.body:
names = targets_of(node) # def / class / assignment
digest = sha256(ast.unparse(node))
Which gives you three things nearly for free:
- Editing a comment or a docstring invalidates nothing.
- The report names the symbol:
rules.py: THRESHOLD, not "something in rules.py". - You can forgive one symbol and keep gating the rest, in writing:
stalegate ack table.csv rules.py:THRESHOLD --why "checked, output identical".
Imports are followed transitively — through packages, through relative imports — so a change three modules deep still counts. And you can declare modules as non_data (plotting, CLI glue, logging) so they can churn freely without ever invalidating an artifact.
The half I couldn't find a tool for
Here's the part that actually bit me hardest, and the part I still haven't found anything else that does.
Data tools track lineage between tables. What none of them track is the sentence in report.md that says "the effect was +2.32 per month" — written by hand, from a table that has since been regenerated.
Nothing rebuilds prose. Nothing tests it. No CI job knows it exists.
So you register the link once:
stalegate docs register notes/report.md "Results" table.csv
and from then on, re-stamping that artifact says so out loud, at the moment it happens:
stalegate: table.csv moved - 1 documented section(s) quote it:
notes/report.md / Results
Re-read them, then: stalegate docs ack <doc> <section> --why ...
stalegate docs status exits non-zero while anything is unconfirmed, so CI can hold the line on your prose the same way it does on your code.
What it deliberately does not do
-
It is not a build system. It refuses to give you stale data; it will not rebuild it.
make,dvc, andsnakemakedo that, and they compose fine with this. - It is not a data-quality checker. It says nothing about whether your numbers are good — only whether they came from the code you're running now.
- The closest existing thing is DVC, which does track stage dependencies and can tell you a stage is outdated. It works at file granularity and asks you to adopt its pipeline and versioning model. This is additive instead: two function calls inside scripts you already have.
Try it
pip install stalegate
Python 3.9–3.13, MIT, no runtime dependencies on 3.11+. There's a runnable four-minute walkthrough in examples/demo — build a table, change the rule, watch the gate refuse, rebuild, and see which paragraph just went stale.
Source: https://github.com/Taisui9/stalegate
It's early. I pulled it out of my own research project, where the first day it ran it caught two real regressions — an output whose generating rule had changed under it, and a set of written-up conclusions quoting a table that had since been rebuilt. If you've solved the prose half some other way, I'd genuinely like to hear about it.
Top comments (0)