rguides

Dependency Management with renv

The companion guide to this one shows you how to spin up a renv project in five minutes. This one goes deeper. The hard part of dependency management in R is not “did I install dplyr?” The real question is: “Exactly which version of dplyr, with exactly which versions of its twenty recursive dependencies, is recorded in a way that survives across machines, six months, and a CI runner that has never heard of your project?” renv::snapshot(), renv::restore(), and renv::status() are the three functions that make this possible. Everything else is plumbing.

The system-library problem

R installs packages into a shared system library by default. The first time you call install.packages("dplyr"), dplyr 1.1.4 lands in something like /usr/local/lib/R/site-library. Six months later, install.packages("dplyr") overwrites it with 1.1.6, and the old behaviour is gone. If you have two projects on the same machine that need different versions of a package, the system library cannot represent that state.

renv fixes this by giving every project its own library at renv/library/. Project A’s dplyr 1.1.4 and Project B’s dplyr 1.1.6 coexist on disk, isolated by directory. The second piece of the system is renv.lock, a JSON file that records what is in that project library at any point in time. Together, the library and the lockfile are the two files that answer “what does this project depend on?”

The renv mental model

Four moving parts, each with a clear job:

  • renv/library/: the per-project package library. Big. Never committed to git.
  • renv.lock: a JSON manifest of every package in the library, with version, source, and hash. Small. Always committed.
  • renv/activate.R plus .Rprofile: a one-line entry in .Rprofile that sources the activator, which makes R use the project library and shim install.packages() / library() calls. Always committed.
  • The package cache, stored outside the project (default: ~/.cache/R/renv) and shared across projects. The cache makes a second renv::restore() on a different project fast because the .tar.gz files are already on disk.

That is the whole architecture. The interesting questions are about what goes into the lockfile and when.

The lockfile, field by field

A minimal renv.lock looks like this:

{
  "R": {
    "Version": "4.4.1",
    "Repositories": [
      { "Name": "CRAN", "URL": "https://cloud.r-project.org" }
    ]
  },
  "Packages": {
    "dplyr": {
      "Package": "dplyr",
      "Version": "1.1.4",
      "Source": "Repository",
      "Repository": "CRAN",
      "Hash": "abc123def456"
    }
  }
}

Two top-level keys. The R block pins the R version (informational only; see “Common pitfalls” below) and the CRAN mirror that was active when the snapshot was written. The Packages block holds one record per package. The Source field is one of Repository (CRAN or a configured repo), Bioconductor, GitHub, GitLab, Bitbucket, or Local. For non-CRAN sources, you also get the remote metadata: RemoteType, RemoteUsername, RemoteRepo, RemoteRef, and RemoteSha. That metadata lets renv::restore() fetch the same commit six months later.

The Hash field is the MD5 of the installed package. This is what renv::status() uses to detect drift: if your library has dplyr 1.1.4 but the lockfile says 1.1.6, the hashes will not match, and you will be told to restore() (or re-snapshot, if you intended to upgrade).

Snapshot types: the most important setting you are not changing

renv::snapshot() discovers what your project uses. How it does that is controlled by type, and the default is the worst choice for most projects.

typeHow it discovers depsWhen to use it
"implicit" (default)Scans R, Rmd, and Qmd files for library() / require() / pkg::fn calls.Quick scripts and analyses. Avoid on large codebases because the scan gets slow.
"explicit"Reads Depends: and Imports: from a DESCRIPTION file in the project root.Recommended for packages, Shiny apps, and any project where you want a hand-curated manifest.
"all"Records every package in the project library, regardless of whether code uses it.Rarely. Useful for “I just want to freeze everything I have right now.”
"custom"Calls a function set via getOption("renv.snapshot.filter").When your project uses a non-standard import mechanism (box::use(), generated code, etc.).

Switch with:

renv::settings$snapshot.type("explicit")

This writes to renv/settings.json (a per-project file, not a global R option). Once you flip to "explicit", you maintain a DESCRIPTION in the project root and renv::snapshot() reads from that. This is the recommended pattern for package projects and for Shiny apps whose deploy target should not depend on whatever the developer happened to have loaded in their console.

The day-to-day workflow

The four commands that cover 95% of use:

# Start a project
renv::init()

# Add a dependency
renv::install("gt")
renv::snapshot()

# Reproduce a project on a new machine
renv::restore()

# Diagnose "it works on my machine"
renv::status()

init() is a one-time setup. It creates renv/library/, renv.lock, the .Rprofile, renv/activate.R, renv/settings.json, and a .gitignore that excludes the library. install() and snapshot() are the day-to-day loop. restore() is what your CI runs and what your teammates run. status() is the diagnostic hub. If something is out of sync, that function tells you which direction to move.

status() produces a four-quadrant decision matrix that is worth memorising:

In lockfileNot in lockfile
Used by codeOKrenv::install() then renv::snapshot()
Not used by coderenv::snapshot() (drops it)OK (often a dev dep)

For version drift, where the library and lockfile contain different versions of the same package, use snapshot() if the code still works and restore() if it does not. Use renv::history() plus renv::revert() if the lockfile itself is broken.

Pinning versions

renv::install() accepts a @ syntax that mirrors the remotes package:

renv::install("dplyr@1.1.0")         # CRAN, pinned to 1.1.0
renv::install("tidyverse/dplyr")     # GitHub: default branch, latest commit
renv::install("tidyverse/dplyr@v1.1.0")  # GitHub tag
renv::install("bioc::DESeq2")        # Bioconductor
renv::install("RSPM@2024-04-01")     # Posit Public Package Manager date snapshot
renv::install("local::./pkg")        # local source tree

After pinning, renv::snapshot() records both the version string and, for remotes, the resolved RemoteSha. That provenance allows restore() to fetch the same commit six months later.

Dev versus runtime dependencies

R has always had a fuzzy line between “packages the code uses” and “packages the developer uses.” Tools such as roxygen2, devtools, testthat, pkgdown, and covr are not loaded by the user-facing code, but the project cannot build without them.

The clean way to model this is a DESCRIPTION file with Imports: (runtime) and Suggests: (dev). Since renv 0.16, Suggests: is no longer recorded in the lockfile by default. To opt back in:

renv::snapshot(dev = TRUE)
renv::status(dev   = TRUE)

What counts as a “dev dependency” in this mode: packages in Suggests: of a project-root DESCRIPTION, roxygen2 and devtools if their use is implied by other project metadata, and packages referenced from ~/.Rprofile if the user-profile config flag is on. For deploy targets such as a Shiny app, a Plumber API, or a Quarto site, leave dev = FALSE so you do not accidentally ship a test framework to production.

Team workflow: what to commit, what to ignore

The rule is simple. Commit renv.lock, .Rprofile, renv/activate.R, and renv/settings.json. Do not commit renv/library/, renv/local/, or renv/staging/. The .gitignore that renv::init() creates for you handles this; verify it is there.

git add renv.lock .Rprofile renv/activate.R renv/settings.json .gitignore
git commit -m "Lock initial package versions"

On a new clone, renv bootstraps itself the first time R is launched in the project directory (the .Rprofile sources the activator, which fetches the right renv version and then prompts for restore()). CI does not get the interactive prompt, so it just runs restore() directly.

CI with r-lib/actions

The official companion is r-lib/actions/setup-renv, which caches the package cache between runs. A minimal GitHub Actions workflow:

- uses: actions/checkout@v4
- uses: r-lib/actions/setup-r@v2
  with:    r-version: 'release'
- uses: r-lib/actions/setup-renv@v2
- name:  Restore library
  shell: Rscript {0}
  run:   renv::restore()
- name:  Run tests
  shell: Rscript {0}
  run:   testthat::test_dir("tests/testthat")

setup-renv keys its cache on the lockfile hash, so the cache invalidates the moment you change a version. Cold runners will still take a few minutes to install everything; warm runners install only the diff.

Common pitfalls

  1. Suggests is no longer snapshotted by default. Pass dev = TRUE if your project relies on pkgdown, covr, or vignette deps.
  2. Transitive dependencies cannot be excluded. exclude and packages are advisory only. If anything you use pulls in Rcpp, Rcpp is in the lockfile.
  3. renv does not control the R version itself. The vignette is explicit on this. Use rig (https://github.com/r-lib/rig) or a Rocker Docker image.
  4. Pandoc is not in the lockfile. Restoring rmarkdown does not guarantee identical rendering; the pandoc R package helps pin the binary.
  5. Binary packages can disappear. If the binary you originally installed is no longer hosted, renv falls back to source. Source builds need system libraries such as gfortran, libxml2, and libssl, which commonly causes trouble on Windows.
  6. Renaming the project directory breaks the lockfile path. renv::activate(project = ".") fixes it.
  7. install.packages() still works because renv shims it. Use whichever you prefer, but renv::install() is the one that knows about GitHub and Bioconductor prefixes.
  8. update.packages() is fine for CRAN, but renv::update() also handles remotes. Prefer the latter.
  9. Snapshot type is per-project. renv::settings$snapshot.type("explicit") writes to renv/settings.json, not to a global R option.

The single most useful habit is to make renv::status() part of the pre-commit check. If a teammate says “it does not work on my machine,” the first reply is always “what does renv::status() say?”

See also