DEV Community

TECHWORKSLAB
TECHWORKSLAB

Posted on Originally published at techworkslab.com

Pinning an R environment with renv so a result still rebuilds in three years

A regulator can ask you to reproduce a result years after you filed it. Not a similar result. The same one, from the same code, to the same digits. That is a high bar for R, because an R script does not carry its dependencies with it. Run it on a machine where one package has moved a minor version and an output can change in ways that are easy to miss and hard to defend.

The renv package exists for exactly this. It gives a project its own private library and writes down, in a lockfile, the exact version and hash of every package the project uses. We treat that lockfile as part of the submission record, not as developer convenience. Here is how we set it up, what it actually guarantees, and the gap you have to close yourself.

Turning a project into a pinned project

One call bootstraps it. renv::init() creates a project local library, scans the code for the packages it loads, installs them into that private library, and writes the first renv.lock.

install.packages("renv")

# From the project root, once:
renv::init()

# From then on, after adding or updating a package:
renv::snapshot()   # record the current state into renv.lock
Enter fullscreen mode Exit fullscreen mode

After init, the project installs into its own library. The global library on the machine no longer matters.

The lockfile is the whole point, so it is worth looking at. It is plain JSON, it is meant to be read, and it belongs in version control next to the code.

{
  "R": {
    "Version": "4.4.1",
    "Repositories": [
      { "Name": "CRAN", "URL": "https://packagemanager.posit.co/cran/2026-09-01" }
    ]
  },
  "Packages": {
    "admiral": {
      "Package": "admiral",
      "Version": "1.1.1",
      "Source": "Repository",
      "Repository": "CRAN",
      "Hash": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Illustrative lockfile excerpt. The version and hash together pin the exact package, not just its name.

Three years later, on a clean machine, one call rebuilds the library the script expects. renv::restore() reads the lockfile and installs each package at the recorded version, verifying the hash as it goes.

# On the machine that has to reproduce the result:
renv::restore()   # installs every package at the version and hash in renv.lock

# Confirm the environment matches the lockfile before you trust an output
renv::status()
Enter fullscreen mode Exit fullscreen mode

renv locks the top of the stack. The bottom of it is your job.

Two gaps renv does not close on its own

renv is excellent at what it does, and it is important to be precise about what it does not do, because the gaps are where false confidence lives.

It does not install R itself. The lockfile records that the project was built under, say, R 4.4.1, but renv::restore() will not put R 4.4.1 on the machine for you. If you restore under a different R version, a package may build differently or refuse to install. So the R version has to be pinned by whatever stands up the machine, not by renv.

By default it does not pin where packages come from in time. If your repository is the live CRAN, then a package that was yanked or superseded can make a restore fail or drift. The fix is to point the project at a dated snapshot repository, so that the restore always sees CRAN as it looked on one fixed day.

# Pin the repository to a fixed date, so a restore sees a frozen CRAN
options(repos = c(
  CRAN = "https://packagemanager.posit.co/cran/2026-09-01"
))
renv::snapshot()   # the dated URL is now recorded in renv.lock
Enter fullscreen mode Exit fullscreen mode

renv pins the packages. A dated repository pins time. A container pins everything below them. You need all three before you can say a result rebuilds.

Closing the last gap with a container

The way we actually guarantee a rebuild is to combine renv with a container built on a fixed base image, for example a versioned Rocker image that carries a specific R version and operating system. The container fixes R and the system libraries; renv fixes the packages; the dated repository fixes where those packages came from. The image is built once, tagged, and kept. Reproducing the result three years on means pulling the tagged image and running renv::restore() inside it, and the answer comes back the same because nothing underneath it was free to move.

This is the practical core of what we wrote about more generally in reproducing the environment, not the answer and in standing up a validated computing environment. For deterministic R code, reproducing the environment is reproducing the answer. renv is the piece of that stack you can adopt this afternoon, and it earns its place long before anyone asks you to prove it.

If you are setting up a reproducible R environment for regulated work and want a second pair of eyes on where the gaps are, we are happy to help. You can reach us here.

Top comments (0)