6  Reproducible Environments

6.1 The Problem This Solves

Six months after you finish a piece of analysis, someone asks you to rerun it. You clone your own repository, run the script, and it fails — a function that used to take three arguments now takes four, or a package you relied on has been renamed, or your machine has a newer Python than the one you wrote it on.

The same problem in its more embarrassing form: you send a colleague your code and it doesn’t run on their machine. Neither of you can tell why, because neither of you can see what’s different.

The cause in both cases is that your analysis had dependencies you never wrote down. The code was in Git. The environment the code needed was in your head, or nowhere.

An environment is the complete set of software your analysis depends on: the language version, every package, and every version of every package. Making that reproducible is the highest-return habit in this book, and unlike most of the material here it is quick to learn.

6.2 The Two Halves

Two things need to happen, and it’s worth separating them because different tools handle each:

  1. Isolation — each project gets its own set of packages, so installing something for project A can’t break project B.
  2. Recording — the exact contents of that set are written to a file, committed alongside the code, and can be reconstructed later or elsewhere.

Isolation on its own protects you from today’s conflicts but not from next year’s. Recording on its own just documents a mess. You want both.

6.3 Choosing a Tool

There are four in common use for Python and one for R. The choice matters less than actually using one, but here is how they differ.

Tool Best when Records to
venv You want the standard library answer with nothing to install requirements.txt
uv Default choice for new Python projects — same model as venv, far quicker to resolve and install, manages Python versions too requirements.txt or uv.lock
conda / Miniforge You need packages with heavy compiled components — GDAL, CUDA-enabled PyTorch, many bioinformatics tools environment.yml
pixi You want conda’s package coverage with a lockfile and a modern CLI pixi.lock
renv R, always renv.lock

For a new Python project with ordinary data science dependencies, use uv. For anything that needs the conda ecosystem’s compiled packages, use Miniforge. For R, renv is the only real answer and it’s a good one.

NoteWhy Miniforge rather than Anaconda

Both give you conda. Anaconda’s Terms of Service restrict free use in organisations above a headcount threshold, which has caught out a number of companies who assumed it was free because the download was. Miniforge is community-maintained, defaults to the conda-forge channel, and carries no such restriction. If you already have Anaconda installed and you’re using it for personal work, it’s fine — but check the current terms before rolling it out at work.

6.4 Python: uv

uv is a single binary that replaces pip, venv, and pyenv. Install it once:

# macOS / Linux
curl -LsSf https://astral.sh/uv/install.sh | sh

# Windows (PowerShell)
powershell -c "irm https://astral.sh/uv/install.ps1 | iex"

Both of these download a script and run it immediately. That is how Astral distributes uv and it is the instruction from their own documentation, but it is worth knowing what you are agreeing to: you are executing whatever is at that address, sight unseen. For installers from a vendor you have reason to trust this is normal practice; for a script you found in a forum post, download it and read it first.

Then, in a project directory:

# Create an environment. uv downloads Python 3.13 if you don't have it.
uv venv --python 3.13

Next, activate it. The command differs by platform — run only the one for yours.

# macOS / Linux
source .venv/bin/activate
# Windows PowerShell
.venv\Scripts\Activate.ps1
:: Windows cmd.exe
.venv\Scripts\activate.bat
WarningWindows: “running scripts is disabled on this system”

A fresh Windows install refuses to run PowerShell scripts at all, so Activate.ps1 fails with cannot be loaded because running scripts is disabled on this system. This is not a problem with your environment. Allow locally-created scripts for your own user account, once:

Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser

RemoteSigned permits scripts you wrote or that a tool generated locally, while still requiring a signature on anything downloaded from the internet. You need this once per machine, not per project.

With the environment active, install into it:

uv pip install pandas matplotlib scikit-learn jupyter

The .venv directory holds the environment. It should never be committed to Git — it’s large, machine-specific, and reconstructible from the record file. Add .venv/ to your .gitignore, a plain text file listing paths Git should leave alone; Editors and Version Control covers it properly and gives a template that already includes this line.

6.4.1 Recording It

uv pip freeze > requirements.txt

The > redirects the command’s output into a file instead of printing it to the screen. Commit requirements.txt. Anyone with it — including you, later — reconstructs the environment with:

uv venv
uv pip install -r requirements.txt

6.5 Python: conda

If you need the conda ecosystem, the shape is the same:

# One environment per project - name it after the project, not after the work
conda create -n sales-forecast python=3.13

# Activate it
conda activate sales-forecast

# Install packages
conda install numpy pandas matplotlib scikit-learn jupyter

The naming matters more than it looks. The datasci environment you created in Python and R is a reasonable scratchpad for working through this book, but it is exactly the pattern this chapter argues against for real work: one shared environment that every project quietly depends on, so upgrading a package for one analysis breaks another. Give each project its own.

Activate it whenever you work on that project. Unlike venv/uv, conda environments live in a central location rather than in the project directory, so there’s nothing to .gitignore.

6.5.1 Recording It

conda env export --from-history > environment.yml

The --from-history flag matters. Without it, conda writes out every package that came along as a dependency of something you asked for — transitive dependencies — each pinned to an exact build string, including builds that only exist for your operating system. The resulting file often won’t install on a different platform at all. With it, you get the packages you actually asked for, which is both readable and portable.

6.6 R: renv

renv gives each R project its own package library and a lockfile recording exactly what’s in it.

install.packages("renv")

Then, from within your project (an RStudio Project, ideally):

# Set up renv for this project — creates a private library
renv::init()

# ... install packages as normal, work on your analysis ...
install.packages("ggplot2")

# Record the current state
renv::snapshot()

renv::snapshot() writes renv.lock, a JSON file — a plain text format of nested "key": value pairs, readable by both people and programs — listing every package, its version, and where it came from. Commit renv.lock. Do not commit the renv/library/ directory — renv::init() adds the right .gitignore entries for you.

Anyone who clones the project runs:

renv::restore()

and gets exactly your package versions.

Warningpackrat is not the answer

Older R material recommends packrat for this. Posit retired it in favour of renv years ago. If you meet a project using packrat, migrating is straightforward — renv::migrate() handles it.

6.7 Connecting the Environment to Your Tools

Creating an environment is half the job. Your editor and your notebooks each have their own idea of which Python to use, and neither picks up a new environment automatically. This is the most common reason people conclude that environments “don’t work”: the environment is fine, but the tool is still running a different one.

Jupyter notebooks. A notebook runs against a kernel, and your new environment is not one until you register it. From inside the activated environment:

# Install the machinery that lets Jupyter talk to this environment
uv pip install ipykernel      # or: conda install ipykernel

# Register it under a name you'll recognise in the kernel menu
python -m ipykernel install --user --name sales-forecast

The environment now appears in Jupyter’s Kernel → Change kernel menu. If a notebook reports that pandas isn’t installed when you know it is, this is almost always the cause — the notebook is running a kernel from a different environment.

VS Code. Press Ctrl+Shift+P (Cmd+Shift+P on macOS), run Python: Select Interpreter, and choose your environment from the list. VS Code detects .venv directories inside the project automatically and usually offers them at the top. The chosen interpreter shows in the status bar, which is worth glancing at when something behaves oddly.

RStudio. renv::init() writes a .Rprofile into the project, so simply opening the RStudio Project activates the private library. The console prints a line confirming it on startup. Nothing further to configure — but this only works if you open the project rather than the individual .R file.

Editors and Version Control covers the editor side in more detail.

6.8 Lockfiles vs Loose Requirements

There’s a distinction worth understanding, because it determines how reproducible your record actually is.

A loose requirement says “pandas”. A pinned requirement says “pandas 3.0.5”. A lockfile says “pandas 3.0.5, and every package pandas itself depends on, at exactly these versions, with a checksum for each — a short fingerprint computed from the file’s contents, which won’t match if the file has been altered or replaced.”

requirements.txt from pip freeze sits in the middle: it pins direct and transitive dependencies to versions, but not to checksums, and it doesn’t distinguish between what you asked for and what came along. renv.lock, uv.lock, and pixi.lock are true lockfiles.

For most analysis work, a pinned requirements.txt is enough. For anything whose result someone will act on months later, prefer a real lockfile. The difference shows up exactly when you need it most.

6.9 What Goes in Git

Commit Never commit
requirements.txt / environment.yml / renv.lock / uv.lock .venv/, venv/, env/
.python-version renv/library/
pyproject.toml __pycache__/, .Rhistory, .RData

The rule underneath: commit the description of the environment, and never the environment itself.

6.10 Making It a Habit

The usual failure mode is drift: you set up an environment, then install three more packages over the following weeks without re-recording. Two things prevent it:

  • Re-snapshot when you install. uv pip freeze > requirements.txt or renv::snapshot() immediately after adding a package, in the same breath as the install. It takes a second.
  • Let CI catch you. A continuous integration workflow (covered in Optimising Workflows) builds your project from nothing but the record file. If you forgot to record something, the build fails — in front of you, on a commit, rather than in front of a colleague six months later. For analysis work this is most of CI’s value.

6.11 Environments and Containers

A reasonable question at this point: if containers (covered in Containerisation) also make things reproducible, why bother with both?

They solve overlapping but different problems. An environment file pins your packages. A container pins the packages and the operating system, the system libraries, the locale, and the compiler toolchain. That extra coverage matters when a package depends on a specific version of a C library, or when the analysis has to run on infrastructure you don’t control.

Use an environment file always; it costs nothing. Reach for a container when the environment file isn’t enough — deployment, awkward system dependencies, or a colleague on a genuinely different platform. The two compose: a good container is usually a base image plus your requirements.txt.

6.12 Conclusion

By the end of this chapter you should have:

  • Every active project in its own isolated environment
  • A requirements.txt, environment.yml, or renv.lock committed alongside the code
  • .venv/ and renv/library/ in your .gitignore
  • Your editor pointed at the right interpreter, and the environment registered as a Jupyter kernel if you use notebooks
  • Successfully reconstructed one of your own environments from its record file — do this once deliberately, so you find out now whether the file is actually complete

That last item is the one people skip. A record file you’ve never restored from is only a hypothesis.