06 · Reproducible Research Practices¶
"It works on my machine" is an especially expensive failure in
research: a result nobody else can regenerate is a result nobody can
trust. renv solves the package half of that problem — pinning exact
package versions to a project instead of your global library — and a
consistent project layout plus version control solves the rest.
Initializing a project library¶
renv::init() creates a private package library scoped to the current
project, plus an .Rprofile that activates it automatically every time
the project is opened:
That message isn't an error — it's renv telling you the project now
has its own library, but the packages your scripts library() haven't
been snapshotted into a lockfile yet. bare = TRUE skips scanning for
and installing existing dependencies immediately, which is useful when
you want to add them incrementally.
After init(), the project directory has:
myproject/
.Rprofile # auto-activates renv for this project
renv/
activate.R # the activation script .Rprofile sources
library/ # project-private package installs live here
renv.lock # not yet written until the first snapshot
Recording exact versions with a lockfile¶
renv::snapshot() scans your R files for library()/require() calls
and records the exact installed version of each into renv.lock:
- The project is out-of-sync -- use `renv::status()` for details.
The following required packages are not installed:
- jsonlite
Packages must first be installed before renv can snapshot them.
Use `renv::dependencies()` to see where this package is used in your project.
The following package(s) will be updated in the lockfile:
# CRAN -----------------------------------------------------------------------
- renv [* -> 1.2.4]
The version of R recorded in the lockfile will be updated:
- R [* -> 4.6.1]
- Lockfile written to ".../renv.lock".
This run is instructive precisely because it's incomplete: the project
script called library(jsonlite), but jsonlite was never installed
into the project's private library — so snapshot() records only what
is installed (renv itself, R's own version) and warns rather than
silently pretending the missing package doesn't matter. Checking
renv::status() afterward confirms the same gap:
- The project is out-of-sync -- use `renv::status()` for details.
The following package(s) are used in this project, but are not installed:
- jsonlite
See `?renv::status` for advice on resolving these issues.
The fix is always renv::install("jsonlite") followed by another
renv::snapshot() — install first, then record.
renv.lock itself is JSON and is meant to be committed to version
control:
{
"R": {
"Version": "4.6.1",
"Repositories": [{ "Name": "CRAN", "URL": "https://cloud.r-project.org" }]
},
"Packages": {
"renv": {
"Package": "renv",
"Version": "1.2.4",
"Source": "Repository"
}
}
}
Restoring a project on a different machine¶
Anyone who clones the repo and opens it in R gets the exact same package versions with one call, which installs into their own project-local library without touching their global one:
This is the payoff: the lockfile plus restore() replaces "please
install these packages, roughly these versions, good luck" with a
single reproducible command.
A reproducible project layout¶
Beyond package versions, a project that another person (or a CI runner) can rerun end to end tends to share this shape:
myproject/
renv.lock # exact package versions — committed
.Rprofile # activates renv — committed
data/
raw/ # original data, never edited in place
processed/ # output of scripts, regenerable, often gitignored
R/ # reusable functions
analysis/
01_clean.R
02_model.R
03_report.Rmd
.gitignore # excludes renv/library/, data/processed/, etc.
The convention worth internalizing: raw data is read-only and
regenerable outputs are never hand-edited — if 02_model.R produces a
.rds file, that file is disposable, and rerunning the numbered scripts
in order should always reproduce it bit-for-bit given the same
renv.lock.
R-specific traps¶
renv::init() rewrites .Rprofile, and if a project already has a
custom .Rprofile for something else, init() appends to it rather
than replacing it — check the file after running init() on an
existing project rather than assuming it started from nothing.
A lockfile records package versions, not R's own base packages or
system dependencies (a compiler for a package with C++ code, a system
library like libcurl) — renv::restore() reproduces the R package
graph, not the OS. Document those separately (a Dockerfile, a README
section) if the project depends on them.
renv's private library is per-project, not per-user — running
renv::restore() on a laptop with limited disk and dozens of renv
projects means dozens of separate copies of common packages like
ggplot2, since renv does not share libraries across projects by
default (it does cache downloads globally, so re-installs are fast, but
each project's renv/library/ is its own full copy).
Cheat sheet¶
| Task | Call |
|---|---|
| Create a project-local library | renv::init() |
| Create one without auto-installing existing deps | renv::init(bare = TRUE) |
| Record exact package versions | renv::snapshot() |
| Check for drift between code and lockfile | renv::status() |
| Reinstall exactly what the lockfile records | renv::restore() |
| Install a package into the project library | renv::install("pkgname") |
| See where a package is used in the project | renv::dependencies() |
How It Actually Works¶
renv::init() doesn't install packages into some private universal
location — it creates a project-local library (a renv/library/
folder) and rewrites .libPaths() for that project (via a generated
.Rprofile that runs automatically every time R starts in that
directory) so package resolution never falls back to your regular
user-wide library. renv::snapshot() then records every package actually
used in your project (detected by static analysis of your library()/
:: calls across your R files) along with its exact installed version and
source (CRAN, GitHub, ...) into renv.lock, a JSON manifest.
renv::restore() reads that lockfile and reconstructs the identical
project library on another machine — for CRAN packages it fetches the
exact archived version from CRAN's version archive rather than "whatever
is current," which is precisely how it produces bit-for-bit reproducible
dependency environments regardless of when or where it's run, as opposed
to a plain install.packages() which always resolves to whatever the
CRAN mirror currently serves as latest.
Exercise¶
- Create a new project directory, run
renv::init(bare = TRUE), write a script that callslibrary(jsonlite), install it withrenv::install("jsonlite"), then runrenv::snapshot()and inspect the resultingrenv.lock. - Delete the project's
renv/librarydirectory (simulating a fresh clone with no packages installed) and runrenv::restore()to confirm it reinstalls exactly what the lockfile specifies. - Sketch a
.gitignorefor the project layout above that keepsrenv.lockand.Rprofiletracked but excludesrenv/library/anddata/processed/.