"It worked on my machine three months ago" is the failure mode data science shares with no other discipline quite so intimately: results depend simultaneously on code, package versions, input data, and random number streams — and any one of the four drifting silently invalidates the other three. Reproducibility is achieved by pinning each layer with a tool built for it. This page walks the stack: environments, randomness, data, and the notebook problem.
A requirements.txt of loose versions (pandas>=2.0) is a description of the past, not a recipe for the future. Reproducible installs need a lockfile — exact versions plus hashes of every transitive dependency. uv has become the standard open-source tool here: a Rust-built resolver/installer that is 10–100x faster than pip and manages the whole project lifecycle:
uv init analysis && cd analysis
uv add polars scikit-learn matplotlib # updates pyproject.toml + uv.lock
uv run python train.py # always executes inside the locked env
uv sync # rebuild the exact env anywhere
uv.lock is committed to git; a colleague (or CI, or you in a year) gets a bit-identical dependency set with uv sync. For stacks with non-Python native dependencies (CUDA toolkits, GDAL, compilers), conda-lock or pixi provide the same locked-solve discipline over conda-forge. Docker images complement but do not replace lockfiles — an image without a locked build recipe just freezes an unreproducible state.
Stochastic steps — splits, initializations, sampling, shuffling — must be seeded to be re-runnable. Modern NumPy practice is explicit Generator objects rather than the global state:
rng = np.random.default_rng(seed=42) # pass rng, not the seed, around
idx = rng.permutation(len(df))
Pass distinct named generators to distinct stochastic components (one for the split, one for the model) so adding a new consumer of randomness doesn't shift every downstream draw. scikit-learn takes random_state per estimator/splitter; set it everywhere.
Know the limits: seeding does not guarantee bit-identical results across library versions, BLAS builds, or hardware — and GPU training is nondeterministic by default (parallel floating-point reduction order). Frameworks offer strict modes (torch.use_deterministic_algorithms(True)) at a speed cost. The practical stance: demand bit-reproducibility within a pinned environment, and statistical reproducibility (conclusions stable across seeds) everywhere else. An analysis whose conclusion flips with the seed has a finding about noise, not about the data — running key results at 5 seeds is a validity check, not just an infrastructure nicety.
Git cannot hold a 10 GB Parquet file; results depend on exactly which 10 GB it was. DVC bridges this: data files are hashed and stored in a remote (S3, GCS, SSH, shared disk), while tiny .dvc pointer files are committed to git — so git checkout of any historical commit plus dvc checkout restores the exact data that commit's code ran against.
dvc init && dvc remote add -d store s3://team-dvc
dvc add data/raw/events.parquet # writes events.parquet.dvc pointer
git add data/raw/events.parquet.dvc && git commit -m "pin raw events"
dvc push # upload content to the remote
DVC also runs pipelines: dvc.yaml declares stages with dependencies and outputs, and dvc repro re-executes only stages whose inputs changed — make-style incremental builds with data awareness. For database- and lakehouse-resident data, per-table versioning tools (lakeFS, Nessie — see Data Versioning) play the equivalent role; the principle is identical: the data revision is part of the result's identity, which is why it belongs logged as a parameter in your experiment tracker.
Notebooks are where analysis happens and where reproducibility dies: hidden state from out-of-order execution means the notebook on disk often cannot produce the outputs it displays. Working countermeasures:
.py files so diffs and reviews work.A result is reproducible when a fresh machine can: git clone (code), uv sync (environment), dvc pull (data), and uv run dvc repro (pipeline, seeds internal) — and arrive at the same tables and figures. Each element maps to one pinned layer; any layer left unpinned is where next quarter's discrepancy will live.