Lesson 3 of 8
Unit · The same answer twice
It works on my machine, and that is the bug report
The same script, the same data and a different pandas version produce different numbers. A lock file is what turns "it works on my machine" from a defence into a testable claim, and it costs one command to make.
What a colleague’s laptop does differently
You send a script and a CSV. They run it. The number differs.
| What differs | What it changes |
|---|---|
| Package version | A default argument, a rounding rule, a sort order |
| Language version | Dictionary ordering, integer division, string handling |
| Operating system | Line endings, file ordering, path separators, locale |
| Locale | 1,234 parsed as 1234 or as 1.234 |
| What is already installed | A library your script imports and never declares |
None of those is in your repository, which is why the code being identical is not enough. A lock file is the missing half of the analysis.
Python: uv
uv init # creates pyproject.toml
uv add pandas matplotlib # records the dependency and resolves it
uv run python run.py # runs inside the pinned environment
Two files, and both are committed. pyproject.toml says what you asked for —
pandas>=2.0 — and uv.lock says exactly what you got, down to the hash of every
package in the tree.
A colleague runs uv sync and has your environment, on their operating system,
without you knowing what they had installed before.
Pin the language version too.
# pyproject.toml
requires-python = ">=3.12,<3.13"
A version range, not a single version. Pinning to 3.12.4 exactly means a
colleague on 3.12.7 cannot run it at all, which is a worse failure than the one you
were preventing.
R: renv
renv::init() # snapshots the project library
renv::snapshot() # after adding a package
renv::restore() # on the colleague's machine
renv.lock is the equivalent artefact and is committed. It records every package,
its version, and the repository it came from, and renv::restore() rebuilds it.
Record the R version, which renv does automatically, and check it in the script
where a difference would matter.
if (getRversion() < "4.3.0") stop("This analysis requires R >= 4.3.0")
# The Python equivalent, at the top of the entry point.
import sys
assert sys.version_info >= (3, 12), "This analysis requires Python 3.12+"
Why a lock file and not requirements.txt
pandas>=2.0
matplotlib
That file says almost nothing. It resolves to different versions on different days
and does not mention the fifty packages those two pull in. A build in March and a
build in July from the same requirements.txt are different environments.
| Declares | Reproduces | |
|---|---|---|
requirements.txt |
Intent | No |
requirements.txt with == pins |
One layer | Partly — transitive dependencies float |
uv.lock / renv.lock |
The whole tree | Yes |
Commit both the intent and the lock. The intent file is what a human edits; the lock is what a machine reproduces from, and neither replaces the other.
What this platform pins, and why one pin is unusual
{
"packageManager": "pnpm@11.9.0",
"engines": { "node": ">=22" },
"devDependencies": { "typescript": "^6.0.3" }
}
The package manager itself is pinned, because a different pnpm resolves the lockfile differently and that is the layer below the layer most projects pin.
TypeScript is held at 6.x deliberately. TypeScript 7 — the native compiler —
does not yet expose the programmatic API that astro check depends on, so
pnpm typecheck fails outright on 7. The pin has a reason, the reason is written
down, and it names the condition for removing it.
That is the shape a pin should have. A version constraint with no comment is a constraint nobody will dare remove and nobody can justify keeping.
# pandas is pinned below 3.0 because the copy-on-write default changes the
# behaviour of the recode in src/clean.py:88. Revisit when that is rewritten.
pandas = ">=2.1,<3.0"
Containers, and when they are worth it
A container pins the operating system as well, which is the one layer a lock file cannot reach.
Worth it when the analysis has non-Python or non-R dependencies — GDAL, a TeX distribution, a database client — or when it has to run unattended on a server, or when the finding will be re-examined years later.
Not worth it when a lock file already reproduces the result and the audience is two colleagues with laptops. A container adds a build step, a registry and a skill that a small M&E team may not have, and the cost is real.
FROM python:3.12-slim
COPY pyproject.toml uv.lock ./
RUN pip install uv && uv sync --frozen
COPY . .
CMD ["uv", "run", "python", "run.py"]
Start with the lock file. Reach for a container when you can name the dependency it is pinning that the lock file cannot.
The test that proves it
Reproduce your own result on a machine that has never seen the project. A fresh clone in a temporary directory is most of the way there.
git clone <repo> /tmp/check && cd /tmp/check
uv sync
uv run python run.py
diff -r outputs/ ~/project/outputs/
# renv::restore() then source("run.R"), and compare.
If you cannot do that, “it is reproducible” is untested. A colleague’s laptop is better and a CI runner is better still, because it is a clean machine every time and it runs whether or not anyone remembers to.
Report it whole
Computational environment
Python 3.12, dependencies pinned in uv.lock (committed). Reproduce with:
uv sync && uv run python run.py
pandas is held below 3.0 because the copy-on-write default changes the
recode in src/clean.py; this is revisited when that function is rewritten.
The pipeline runs on every push in CI on a clean machine, so the claim
that a fresh checkout reproduces these outputs is tested rather than
asserted.
Analysis run on 2026-03-14 with commit a3f9c21.
The last line is the one that makes the rest usable a year later. A result without the commit that produced it can be approximately reproduced; with it, exactly.
What comes next
A pinned environment still produces a different answer on a second run if the code reads the clock, draws a random number, or trusts the order files come back in. The next lesson finds all four.