Reproducible Environments with renv

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

Checked against R 4.6.1 (2026-06-24) on 2026-10-20; R 4.6.2 is scheduled for 2026-10-29. This page uses renv 1.3.1. The scratch project it builds has two packages of its own, jsonlite 2.0.0 and R6 2.6.1, and the examples add two more from CRAN, tinytest 1.4.3 and magrittr 2.0.5; the lintr 3.4.0 default linters were used only to check the scripts, and git is used for the history example. Everything else (rig, Docker, the rocker images, GitHub Actions) is described from its documentation and shown as a fragment that was not run, except that the rocker/r-ver:4.6.1 image was started once to check that it reports R 4.6.1. R finds the packages a script loads in a library, a directory of installed packages, and by default every project on a machine shares one or two of them. That is convenient until two projects need different versions of the same package, until an update for one project breaks another, or until a colleague runs your script with newer versions and gets different numbers. The renv package gives each project its own library, records exactly which versions are in it in a text file called a lockfile, and can rebuild the library from that file on another machine. This page explains the project library, the lockfile and the cache, the cycle of snapshot(), status() and restore(), how renv decides which packages a project uses, the settings that change its behaviour, and how to put renv into CI and Docker. It then looks at what renv cannot do on its own, which is to pin the version of R itself, and at the tools that can: rig and the rocker images.

Every example is a script that was run with Rscript on R 4.6.1, and the lines that start with #> are what it printed. The scripts also pass the default lintr linters. They run in the order in which they appear on the page, inside one scratch project, so a later script depends on the state the earlier ones left. Some of the scripts need the network (renv::install() of a package that is not already cached downloads it from CRAN, and the fresh checkout downloads renv and the packages), and each such script says so in a comment. Paths in the output are shortened: the project directory is shown as ~/project, the cache as ~/.cache/R/renv/cache (its default location on Linux; in these runs RENV_PATHS_CACHE pointed at a scratch directory), and the operating-system and platform parts of library and cache paths as <os> and <platform>. Progress bars, timings and terminal control characters, which renv writes while it installs, are filtered out of the output; the scripts that need this say so. The running shelf scenario of the section (books, loans, shelf_today) is not needed here, because the point of this page is the environment and not the analysis; the example project is a tiny report.R whose member names come from the scenario.

Why a project library

R’s own vocabulary is the first thing to get straight. The R FAQ answers "What is the difference between package and library?" with a definition that renv’s documentation repeats: a library is a directory that contains installed packages, and a package is what library(dplyr) loads from one. A repository is a source of packages, such as CRAN, Bioconductor, Posit Public Package Manager or an r-universe, that install.packages() downloads from into a library. .libPaths() lists the libraries R searches, in order, and getOption("repos") lists the repositories it installs from. Both are covered, with install.packages() and update.packages(), on Packages, Libraries and Namespaces. renv changes the first list: a project that uses renv has its own library first on .libPaths(), and nothing from the user or site libraries after it except the packages that ship with R.

The renv introduction names the three gains this buys. Isolation: installing, updating or removing a package in one project cannot affect another. Portability: the lockfile records enough metadata about every package for it to be installed again on another computer. Reproducibility: the lockfile records the versions, so the same versions come back. The cost is also stated in the documentation: an isolated project never benefits from bug fixes in its packages unless somebody updates it, so the advice is to run renv::update() regularly (at least once a year, says the introduction), re-run the code, and snapshot again.

renv is also explicit about what it does not do. It tracks the version of R in the lockfile but cannot change it, because it runs inside R. It does not restore pandoc, which rmarkdown needs and does not bundle. It does not control the operating system, the system libraries, the compilers or the BLAS and LAPACK libraries that compiled packages are built against, and package installation can fail if a package was originally installed from a binary that is no longer available, because renv then tries to build it from source and may lack the system prerequisites. The last sections of this page return to R itself, and to the system layer, with rig and Docker. The renv documentation also notes that the workflow is generally a poor fit for a CRAN package, because CRAN checks a package against the latest versions of its dependencies; the usual place for renv is an analysis, an application or a report, not a library.

An example project

The project below lives in ~/project. It is a script that uses jsonlite and R6, one with library() and one with ::. The first script writes report.R, shows it, and asks renv which packages it finds. renv::dependencies() returns a data frame that maps packages to the files in which they were found; its Source column holds absolute paths, so the script keeps only the file names.

# Create the files of a small analysis project, then ask renv what it uses.
writeLines(c(
  "# Loan summary for the shelf project.",
  "library(jsonlite)",
  "",
  "loans_per_member <- data.frame(",
  "  member = c(\"Ana Ruiz\", \"Dev Patel\"),",
  "  loans = c(7L, 9L)",
  ")",
  "cat(toJSON(loans_per_member, pretty = TRUE), \"\\n\")",
  "",
  "counter <- R6::R6Class(\"Counter\", public = list(n = 0L))",
  "counter$new()$n"
), "report.R")

cat(readLines("report.R"), sep = "\n")
#> # Loan summary for the shelf project.
#> library(jsonlite)
#>
#> loans_per_member <- data.frame(
#>   member = c("Ana Ruiz", "Dev Patel"),
#>   loans = c(7L, 9L)
#> )
#> cat(toJSON(loans_per_member, pretty = TRUE), "\n")
#>
#> counter <- R6::R6Class("Counter", public = list(n = 0L))
#> counter$new()$n

deps <- renv::dependencies()
#> Finding R package dependencies ... Done!
deps$Source <- basename(deps$Source)
deps
#>     Source  Package Require Version   Dev
#> 1 report.R jsonlite                 FALSE
#> 2 report.R       R6                 FALSE

Before this point renv has not been used in the directory, and dependencies() works anyway: it only reads files. The Require and Version columns stay empty for packages found in code, and Dev is TRUE for development dependencies, which are described below.

How renv finds the packages a project uses

Three of renv’s functions start from the same question: which packages does this project use? init() installs them, snapshot() (with the default snapshot type) records them, and status() compares them with the lockfile and the library. The answer comes from renv::dependencies(), which is static analysis: it parses the code and looks at what it says, and it does not run anything. The help page lists what it recognises: calls of the form library(package), require(package), requireNamespace("package") and package::method() (and :::), the box::use() and pacman::p_load() loaders, the rlang::check_installed() and rlang::is_installed() checks, and, in later releases, base::use() (renv 1.1.5), data(dataset, package = "package") (1.1.6) and system.file(..., package = "package") (1.2.0). It reads .R files, R Markdown and Quarto documents, DESCRIPTION files and Jupyter notebooks. R Markdown files need the rmarkdown package, and the YAML header needs yaml; in a project library that has neither, dependencies() warns that the yaml package is required to parse dependencies within R Markdown files, and suggests installing it.

The script builds a scratch tree with a script, a notebook-style document and a DESCRIPTION, and prints which package was found in which file. It runs before the project is initialised, because the project library below will not contain rmarkdown and yaml.

# Build a scratch tree with several kinds of files and see what renv finds.
proj <- file.path(tempdir(), "scratch")
dir.create(file.path(proj, "data"), recursive = TRUE)

writeLines(c(
  "library(dplyr)",
  "suppressPackageStartupMessages(library(\"ggplot2\"))",
  "requireNamespace(\"stringr\", quietly = TRUE)",
  "jsonlite::toJSON(1)",
  "data.table:::fread",
  "for (p in c(\"lubridate\", \"forcats\")) library(p, character.only = TRUE)"
), file.path(proj, "analysis.R"))

writeLines(c(
  "---",
  "title: Notes",
  "---",
  "",
  "```{r}",
  "library(knitr)",
  "```",
  "",
  "```{r, eval=FALSE}",
  "library(DBI)",
  "```",
  "",
  "```{r, renv.ignore=TRUE}",
  "library(shiny)",
  "```"
), file.path(proj, "notes.Rmd"))

writeLines(c(
  "Package: scratch",
  "Version: 0.0.1",
  "Imports: cli",
  "Suggests: testthat"
), file.path(proj, "DESCRIPTION"))

# An old script in a data folder: renv reads it too.
writeLines("library(tibble)", file.path(proj, "data", "old-script.R"))

used_packages <- function(...) {
  deps <- renv::dependencies(proj, root = proj, quiet = TRUE, ...)
  deps$Source <- sub(paste0(proj, "/"), "", deps$Source, fixed = TRUE)
  deps[c("Source", "Package", "Dev")]
}
used_packages()
#>              Source    Package   Dev
#> 1        analysis.R data.table FALSE
#> 2        analysis.R      dplyr FALSE
#> 3        analysis.R    ggplot2 FALSE
#> 4        analysis.R   jsonlite FALSE
#> 5        analysis.R    stringr FALSE
#> 6 data/old-script.R     tibble FALSE
#> 7       DESCRIPTION        cli FALSE
#> 8         notes.Rmd  rmarkdown FALSE
#> 9         notes.Rmd      knitr FALSE

# Development dependencies (Suggests) are reported only when asked for.
used_packages(dev = TRUE)
#>               Source    Package   Dev
#> 1         analysis.R data.table FALSE
#> 2         analysis.R      dplyr FALSE
#> 3         analysis.R    ggplot2 FALSE
#> 4         analysis.R   jsonlite FALSE
#> 5         analysis.R    stringr FALSE
#> 6  data/old-script.R     tibble FALSE
#> 7        DESCRIPTION        cli FALSE
#> 8        DESCRIPTION   testthat  TRUE
#> 9          notes.Rmd  rmarkdown FALSE
#> 10         notes.Rmd      knitr FALSE

# Static analysis cannot see the loop over package names; a file named
# _dependencies.R lists such packages with plain library() calls.
writeLines(
  c("library(lubridate)", "library(forcats)"),
  file.path(proj, "_dependencies.R")
)
used_packages()
#>               Source    Package   Dev
#> 1    _dependencies.R    forcats FALSE
#> 2    _dependencies.R  lubridate FALSE
#> 3         analysis.R data.table FALSE
#> 4         analysis.R      dplyr FALSE
#> 5         analysis.R    ggplot2 FALSE
#> 6         analysis.R   jsonlite FALSE
#> 7         analysis.R    stringr FALSE
#> 8  data/old-script.R     tibble FALSE
#> 9        DESCRIPTION        cli FALSE
#> 10         notes.Rmd  rmarkdown FALSE
#> 11         notes.Rmd      knitr FALSE

# A .renvignore file (gitignore syntax) keeps renv out of folders such as data/.
writeLines("data/", file.path(proj, ".renvignore"))
used_packages()
#>             Source    Package   Dev
#> 1  _dependencies.R    forcats FALSE
#> 2  _dependencies.R  lubridate FALSE
#> 3       analysis.R data.table FALSE
#> 4       analysis.R      dplyr FALSE
#> 5       analysis.R    ggplot2 FALSE
#> 6       analysis.R   jsonlite FALSE
#> 7       analysis.R    stringr FALSE
#> 8      DESCRIPTION        cli FALSE
#> 9        notes.Rmd  rmarkdown FALSE
#> 10       notes.Rmd      knitr FALSE

Each step shows a rule of the scan. The first listing finds dplyr (a library() call), ggplot2 (a quoted library() argument), stringr (requireNamespace()), jsonlite (a :: call) and data.table (a ::: call) in analysis.R, rmarkdown and knitr in the document, and cli from the Imports field of the DESCRIPTION. It does not find DBI, whose library() call is in a chunk with eval=FALSE, because renv skips chunks that are not evaluated, nor shiny, in a chunk marked renv.ignore=TRUE; renv.ignore=FALSE forces a chunk to be read. It also does not find lubridate and forcats, because the loop for (p in c(...)) library(p, character.only = TRUE) hides the names from a parser. The documentation gives that very loop as an example of what static analysis cannot see, and gives the escape hatch used in the script: a file called _dependencies.R that holds plain library() calls. A package that only another package needs at run time cannot be seen either (tidyr::separate_wider_delim() needs stringr, which tidyr only suggests); for a few common cases renv infers the indirect dependency (dplyr with DBI and RSQLite implies dbplyr), and options(renv.dependencies.implied = list()) turns the inference off.

testthat appears in the second listing, with Dev set to TRUE: a package in the Suggests field of the project’s DESCRIPTION is a development dependency, one that people need while working on the project and the computer deploying it does not. snapshot() and status() leave development dependencies out of the lockfile by default (renv 1.0.0), while renv::install() with no arguments installs them. The snapshot.dev setting (renv 1.1.6) turns them on for snapshot() and status(). Packages used in the user’s ~/.Rprofile also count as development dependencies, when config$user.profile() is TRUE.

The last listing shows the other control on the scan. renv reads the project’s .gitignore files to decide which files to skip, and a .renvignore file with the same syntax takes its place in the folder that contains it; data/old-script.R disappears once data/ is ignored. Ignoring matters for speed (renv reports when a scan finds more than 1000 files, renv.config.dependencies.limit) and for errors: a file that does not parse is reported, not fatal, unless errors = "fatal" or the config option dependency.errors says so. Profile-specific sections in these files start with a comment of the form #| profile == "dev".

The renv FAQ turns this into the answer to the most common question: a package enters the lockfile only if it is both installed in the project library and used by the project according to dependencies(). To record every installed package whatever the code says, use the all snapshot type below; to list packages one by one, use explicit; to keep a package out, add it to the ignored.packages setting.

Starting with renv::init()

renv::init() turns a project into an renv project. Its help page lists four steps: set up the project infrastructure (the project library and the .Rprofile line that activates it), discover the packages the project uses with dependencies() and install them into the project library (the job of hydrate()), write a lockfile that records the library (the job of snapshot()), and restart R if it runs inside RStudio. If the project already uses renv, init() restores the library when it is missing and otherwise asks what to do. The main arguments are in the table.

Argument Meaning

project

The project directory; the default is the working directory, and R’s working directory is changed to match a different one.

bare

TRUE creates an empty project library and does not discover or install any packages.

profile

The renv profile to activate, for projects that keep several libraries and lockfiles side by side (see the profiles article).

settings

A list of project settings for the new project, for example list(snapshot.type = "explicit").

repos

The repositories to use in this project and to record in the lockfile.

bioconductor

The Bioconductor version to use; TRUE takes the version the BiocManager package recommends.

force

Allow initialising the home directory, which renv otherwise refuses.

load, restart

Whether to load the project after initialising it, and whether to try to restart the R session.

There is one point about repositories that decides what the lockfile will say. If the repos option has not been set by some other means, init() uses the Posit Public Package Manager CRAN mirror, because it can serve pre-built binaries on common Linux distributions; the NEWS for renv 1.0.0 introduced this. The scripts here set the option to the public CRAN address first so that the lockfile records that address, which is also what a project does when its .Rprofile or Rprofile.site sets repos. The initial run:

options(repos = c(CRAN = "https://cloud.r-project.org"))
renv::init()
#> - Linking packages into the project library ... Done!
#> The following package(s) will be updated in the lockfile:
#>
#> # RSPM -----------------------------------------------------------------------
#> - jsonlite   [* -> 2.0.0]
#> - R6         [* -> 2.6.1]
#> - renv       [* -> 1.3.1]
#>
#> The version of R recorded in the lockfile will be updated:
#> - R          [* -> 4.6.1]
#>
#> - Lockfile written to "~/project/renv.lock".
renv::status()
#> No issues found -- the project is in a consistent state.

The report reads from the top: renv linked the packages the project uses into the new project library (the packages came from the libraries that were already on the machine, which is what hydrate() does), then snapshot() listed what it would write into the lockfile and wrote it. The heading # RSPM --- above the three packages names the repository recorded in each package’s own DESCRIPTION (packages from different repositories would be listed under one heading each): the packages of the machine used here were built from Posit Public Package Manager binaries, whose Repository field says RSPM, whereas packages installed from CRAN say CRAN. The lockfile also records renv itself, so that a restore can bring back the same version of the tool, and the version of R, 4.6.1. renv::status() then confirms that the library, the lockfile and the code agree.

renv::init(bare = TRUE) is the variant for starting with an empty library, and it is the form the examples in the renv help pages use. A project that needs no packages yet, or one that wants to install everything itself, can use it, and a later renv::snapshot() records whatever has been installed and used.

What init() leaves behind

The second script looks at the project from the inside. It is run in a new R session, as the next script of any workflow would be, and that matters: nobody calls renv::load() here. The session started in ~/project, R read the project’s .Rprofile, and that line activated the project.

# What init() left behind, seen from inside the activated project.
renv::project()
#> [1] "~/project"
list.files(all.files = TRUE, no.. = TRUE)
#> [1] ".Rprofile" "renv"      "renv.lock" "report.R"
readLines(".Rprofile")
#> [1] "source(\"renv/activate.R\")"
list.files("renv", all.files = TRUE, no.. = TRUE)
#> [1] ".gitignore"    "activate.R"    "library"       "settings.json"
readLines("renv/.gitignore")
#> [1] "library/" "local/"   "cellar/"  "lock/"    "python/"  "sandbox/" "staging/"
cat(readLines("renv/settings.json"), sep = "\n")
#> {
#>   "bioconductor.enabled": true,
#>   "bioconductor.version": null,
#>   "external.libraries": [],
#>   "ignored.packages": [],
#>   "lockfile.sanitize": true,
#>   "package.dependency.fields": [
#>     "Imports",
#>     "Depends",
#>     "LinkingTo"
#>   ],
#>   "ppm.enabled": null,
#>   "ppm.ignored.urls": [],
#>   "r.version": null,
#>   "snapshot.dev": false,
#>   "snapshot.type": "implicit",
#>   "use.cache": true,
#>   "vcs.ignore.cellar": true,
#>   "vcs.ignore.library": true,
#>   "vcs.ignore.local": true,
#>   "vcs.manage.ignores": true
#> }

# The project library comes first on the library paths; the packages
# the project installed are links into the cache (renv itself is a copy).
.libPaths()[1]
#> [1] "~/project/renv/library/<os>/R-4.6/<platform>"
length(.libPaths())
#> [1] 2
lib <- renv::paths$library()
list.files(lib)
#> [1] "jsonlite" "R6"       "renv"
nzchar(Sys.readlink(file.path(lib, c("R6", "jsonlite", "renv"))))
#> [1]  TRUE  TRUE FALSE

# Nothing outside the project library and base R is visible.
tryCatch(library(dplyr), error = \(e) conditionMessage(e))
#> [1] "there is no package called ‘dplyr’"

# renv puts shims on the search path so install.packages() goes through renv.
find("install.packages")
#> [1] "renv:shims"    "package:utils"

The files are those the introduction lists, plus the directory renv keeps its machinery in:

Path Role

.Rprofile

The project R profile. renv writes one line, source("renv/activate.R"), which R runs at the start of every session started in this directory. If the file already exists, renv adds the line to it.

renv.lock

The lockfile: a JSON file that records R, the repositories and every package needed to rebuild the library.

renv/activate.R

The autoloader. It finds or installs the renv version the project asks for (version <- "1.3.1" at the top of the file), sets the library paths and loads the project. About 1,450 lines of R, written by renv; do not edit it.

renv/settings.json

The project settings (see below), as JSON.

renv/library/

The project library, in subdirectories by operating system, R version and platform. It is private to the machine, and renv/.gitignore keeps it out of version control.

renv/.gitignore

Lists library/, local/, cellar/, lock/, python/, sandbox/ and staging/, the directories that belong to one machine.

Four details in the output are worth reading slowly. The project library is first on .libPaths(), and the only other entry is R’s own library of base and recommended packages: the user and site libraries are gone, which is why library(dplyr) fails in the script even though dplyr is installed on the machine. The error is the ordinary one for a missing package; a project that needs dplyr has to install it into its own library. jsonlite and R6 in the project library are symbolic links into the cache, which the later section on the cache explains, while renv itself is a real directory, not a link. And find("install.packages") reports renv:shims before package:utils: renv puts shims on the search path so that the habitual install.packages(), update.packages() and remove.packages() call renv::install(), renv::update() and renv::remove() instead, and the shim can be bypassed by writing utils::install.packages(). renv::load(), which the autoloader calls, is the function that sets all this up, including a sandbox of the system library that hides non-base packages (the sandbox is configurable, renv.config.sandbox.enabled).

Activation: how R finds the project

The activation path is plain R startup. When R starts it runs the user profile, which is the .Rprofile of the working directory (or of the home directory when the working directory has none), unless R_PROFILE_USER names another file or --no-init-file is given. This is the mechanism described on Sessions, Workspaces and Startup, and renv depends on it. Three consequences are worth knowing.

  • A session is only in the project if it started in the project directory, or the project was loaded with renv::load("path/to/project"). Opening the project folder in RStudio or Positron, or running Rscript report.R from that folder, gets the autoloader; running Rscript ~/project/report.R from the home directory does not, and neither does starting R with a different R_PROFILE_USER.

  • activate.R does nothing when the autoloader is disabled. options(renv.config.autoloader.enabled = FALSE) or the environment variable RENV_CONFIG_AUTOLOADER_ENABLED=FALSE turn it off, and so does a sub-process of R CMD in which R_LIBS is already set (the script takes it that the parent process has set up the library paths). In that case the script loads the user profile by hand, so the rest of the user’s startup is not lost.

  • The autoloader sets options(renv.consent = TRUE). renv writes into the project and into its global cache directory, and CRAN policy says a package may only do that with the user’s consent; renv::consent() records it, and the autoloader and renv::init() take care of it.

A project can be switched off for one session without touching any file by setting the environment variable. The section on turning renv off below shows the permanent version, renv::deactivate().

The lockfile

renv.lock is JSON, so that other tools can read it. The lockfile article describes four kinds of record: the version of R, the repositories that were active when the lockfile was written, and one record per package with its version and installation source. The lockfile written here has the two keys R and Packages, and renv itself is one of the package records. The script prints the top of the file, reads it back with renv::lockfile_read(), which returns a list, and then writes the same snapshot in the older, compact format that renv produced before version 1.1.0, so that a record fits on a screen, and prints its first record. In that format each package carries a Hash, and the hash is the name of the directory the package has in the cache.

# The lockfile is JSON; its header records R and the repositories.
cat(readLines("renv.lock", n = 11), sep = "\n")
#> {
#>   "R": {
#>     "Version": "4.6.1",
#>     "Repositories": [
#>       {
#>         "Name": "CRAN",
#>         "URL": "https://cloud.r-project.org"
#>       }
#>     ]
#>   },
#>   "Packages": {

lock <- renv::lockfile_read()
names(lock)
#> [1] "R"        "Packages"
lock$R$Version
#> [1] "4.6.1"
vapply(lock$Packages, \(pkg) pkg$Version, character(1))
#>       R6 jsonlite     renv
#>  "2.6.1"  "2.0.0"  "1.3.1"
length(lock$Packages$R6)
#> [1] 19
head(names(lock$Packages$R6), 10)
#>  [1] "Package"     "Version"     "Source"      "Title"       "Authors@R"
#>  [6] "Description" "License"     "URL"         "BugReports"  "Depends"

# renv before 1.1.0 wrote a compact record with a Hash instead.
options(renv.lockfile.version = 1)
old <- tempfile(fileext = ".lock")
invisible(capture.output(renv::snapshot(lockfile = old, prompt = FALSE)))
cat(readLines(old, n = 21), sep = "\n")
#> {
#>   "R": {
#>     "Version": "4.6.1",
#>     "Repositories": [
#>       {
#>         "Name": "CRAN",
#>         "URL": "https://cloud.r-project.org"
#>       }
#>     ]
#>   },
#>   "Packages": {
#>     "R6": {
#>       "Package": "R6",
#>       "Version": "2.6.1",
#>       "Source": "Repository",
#>       "Repository": "RSPM",
#>       "Requirements": [
#>         "R"
#>       ],
#>       "Hash": "d4335fe7207f1c01ab8c41762f5840d4"
#>     },

# The Hash is the name of the package's directory in the cache.
basename(dirname(Sys.readlink(file.path(renv::paths$library(), "R6"))))
#> [1] "d4335fe7207f1c01ab8c41762f5840d4"

The table lists the fields of a record. In the format of renv 1.1.0 and later the record is a copy of the installed package’s DESCRIPTION fields, which is why the R6 record above has 19 fields, of which the script prints the first ten; the compact format keeps only the first group.

Field Meaning

R$Version

The version of R the lockfile was written with. status() compares it with the running R; the r.version project setting overrides it.

R$Repositories

A list of Name and URL pairs, taken from getOption("repos") when the lockfile was written. A restore uses these repositories unless told otherwise (repos argument, or the repos.override config option).

Package, Version

The name and version of the package.

Source

Where the package came from: Repository for CRAN and other CRAN-like repositories, GitHub for a package installed from GitHub, and similar values for Bioconductor, GitLab, Bitbucket and plain git remotes. A GitHub record also carries RemoteType, RemoteHost, RemoteUsername, RemoteRepo, RemoteRef and RemoteSha fields, which pin the exact commit.

Repository

For Source: Repository, the name of the repository as the installed package’s DESCRIPTION states it (CRAN, RSPM and so on).

Requirements

The packages the package depends on (compact format).

Hash

A hash of the package’s description; it names the directory in the cache (compact format; the full format does not store it, and the hash can be recomputed from the record).

Title, Description, Depends, Imports, Suggests, Maintainer, NeedsCompilation, …​

The rest of the installed package’s DESCRIPTION (renv 1.1.0 and later).

A lockfile can be edited by hand, and the renv FAQ says what that costs: a record without a Hash cannot use the global cache, so the package is always installed again. renv::lockfile_validate() checks a lockfile against renv’s JSON schema (it needs the jsonvalidate package, and since renv 1.3.1 it says so clearly when it is missing). The family lockfile_create(), lockfile_read(), lockfile_write() and lockfile_modify() reads and writes lockfiles from code, and renv::record() adds or removes a single package record. The format changed in renv 1.1.0: records now include more of the DESCRIPTION, which lets other tools recompute the hash from the lockfile alone, and options(renv.lockfile.version = 1), or the environment variable RENV_LOCKFILE_VERSION=1, asks for the compact one. renv 1.2.0 strips credentials embedded in repository URLs (https://user:token@host/path becomes https://host/path) when it writes the lockfile, which the lockfile.sanitize setting controls.

The daily cycle: install, snapshot, status, restore

The working loop has four verbs, and the first two are the ones a person types most. The code starts to use a package, which is installed into the project library; once the code works, renv::snapshot() records the library in the lockfile; the lockfile goes into version control; and anybody else, or the same person on another computer, runs renv::restore() to get the library back. renv::status() reports whether the three things that should agree really do: the code (what dependencies() finds), the library and the lockfile.

The next script plays the first half. The project starts to use tinytest, a small testing package that is not installed. status() notices, renv::install() installs it (this needs the network, because tinytest is not in the cache yet; the script prints only the summary lines of renv’s progress report), and status() notices again that the library has a package that the lockfile does not know about.

# The project starts to use a new package, tinytest, that is not installed yet.
write("library(tinytest)", "report.R", append = TRUE)
renv::status()
#> The following package(s) are used in this project, but are not installed:
#> - tinytest
#>
#> See `?renv::status` for advice on resolving these issues.

# renv::install() needs the network: tinytest is not in the cache yet.
output <- capture.output(renv::install("tinytest"))
grep("^(The following|- )", output, value = TRUE)
#> [1] "- Querying repositories for available source packages ... Done!"
#> [2] "The following package(s) will be installed:"
#> [3] "- tinytest [1.4.3]"
packageVersion("tinytest")
#> [1] ‘1.4.3’

# The library has it and the code uses it, but the lockfile does not know it.
renv::status()
#> The following package(s) are in an inconsistent state:
#>
#>  package  installed recorded used
#>  tinytest y         n        y
#>
#> See `?renv::status` for advice on resolving these issues.

The two reports are the two halves of one table in the status() help page. The help page says that the function checks installation first, because a missing package could be a dependency of a package that is used, and only then compares the lockfile with the code, and then versions.

Installed Recorded Used What to do

no

yes

yes

renv::restore() installs the version in the lockfile.

no

no

yes

renv::install() installs it from CRAN or another source.

no

yes

no

renv::snapshot() removes it from the lockfile.

yes

no

yes

renv::snapshot() adds it to the lockfile (the "inconsistent state" table above).

yes

yes

no

renv::snapshot() removes it from the lockfile.

yes (different version)

yes

yes

renv::snapshot() if the code works, which makes the library the truth; renv::restore() if it does not, which makes the lockfile the truth. When unsure, the help page says snapshot() is the safer of the two, and that history() and revert() can roll back to an earlier known good state.

When R starts in a project, renv runs the same check and prints a one-line notice. A new session started now, with tinytest installed and not recorded, begins with the first line below, and a session that starts in a fresh clone, where the project library does not exist yet, begins with the two lines of the second block. Both come from renv::load(), and the check can be switched off with the synchronized.check config option.

- The project is out-of-sync -- use `renv::status()` for details.
- None of the packages recorded in the lockfile are currently installed.
- Use `renv::restore()` to restore the project library.

The second half of the loop, run in that new session, is snapshot() and restore(). The script also removes the package again to show a restore with something to do: the report names the package, its version and where it comes from, and, because tinytest is in the cache, the install is a link. A restore with nothing to do ends in one line.

# snapshot() writes the library state to renv.lock.
renv::snapshot()
#> The following package(s) will be updated in the lockfile:
#>
#> # CRAN -----------------------------------------------------------------------
#> - tinytest   [* -> 1.4.3]
#>
#> - Lockfile written to "~/project/renv.lock".
renv::status()
#> No issues found -- the project is in a consistent state.

# A second snapshot has nothing to do.
renv::snapshot()
#> - The lockfile is already up to date.

# Lose a package from the project library, then get it back from the lockfile.
renv::remove("tinytest")
#> - Removing package(s) from project library ...
#> Removing package 'tinytest' ... Done!
renv::status()
#> The following package(s) are used in this project, but are not installed:
#> - tinytest
#>
#> See `?renv::status` for advice on resolving these issues.
output <- capture.output(renv::restore())
linked <- grep("linked", output, value = TRUE)
cat(gsub("\033\\[[0-9;?]*[A-Za-z]", "", linked), sep = "\n")
#> ✔ tinytest 1.4.3                           [linked from cache]
renv::status()
#> No issues found -- the project is in a consistent state.

# restore() with nothing to restore.
renv::restore()
#> - The library is already synchronized with the lockfile.

snapshot() prints what it would change in the lockfile, in the form [old -> new] where a star means "not recorded", writes the file, and when called interactively it asks first (prompt = interactive()); renv 1.0.0 stopped asking for confirmation when the lockfile does not exist yet, since deleting it undoes the write. It checks the library before writing (the snapshot.validate config option, on by default) and diagnoses problems such as an installed package whose dependency is missing. Its other arguments are worth knowing: lockfile writes elsewhere, and lockfile = NULL returns the lockfile as an R object without writing, which the snapshot-type section uses; packages and exclude restrict what is recorded (a package’s dependencies are always added, so the lockfile stays complete); update = TRUE keeps the records of packages that are not in the library any more; repos chooses the repositories recorded; type selects the snapshot type; and force writes even when the checks failed. Setting renv.config.auto.snapshot to TRUE makes renv snapshot after each change without being asked, and is off by default.

restore() compares the lockfile with the project library and installs, from the lockfile’s records, the packages that differ. Its arguments change how it does that.

Argument Meaning

lockfile

The lockfile to restore from; the default is the project’s renv.lock.

packages, exclude

Restore a subset of the packages, or all but a subset; the dependencies the subset needs are restored too, and an exclude that a restored package needs is ignored.

rebuild

TRUE, or a vector of package names, forces them to be built again, bypassing the installed copies and the cache.

repos

Which repositories to use for the packages from CRAN-like repositories. By default the ones recorded in the lockfile, so that a package comes from the mirror it came from; repos = getOption("repos") uses the current session’s instead.

clean

TRUE removes packages that are not recorded in the lockfile from the library, so that the library mirrors the lockfile exactly.

strict

TRUE retrieves a package whose record carries a repository URL only from that exact repository and fails if it is unavailable (renv 1.2.0).

transactional

By default the project library changes only if every package installs; if one fails, the restore is rolled back. FALSE keeps the packages that installed, which helps when a few fail and others should be repaired by hand.

retry

Try again with the latest available version of a package whose recorded version fails to install, which helps when restoring after an R upgrade. In a non-interactive session it must be asked for with retry = TRUE.

prompt

Ask before acting; the default is to ask in interactive sessions only.

Since renv 1.2.0 install() and restore() download and install packages in parallel: downloads are batched into one curl --parallel call and source packages are built concurrently, up to install.jobs at a time (default 4), each as soon as its dependencies are ready. Binary packages are installed first because they need no build order.

The global cache

Restoring a project that needs forty packages would be slow if every project had to download, and for packages with C or Fortran code compile, its own copies. The renv package avoids that with a cache that all of a user’s renv projects share. The package-installation article describes what happens when a package is requested, whether by install.packages() (through the shim), renv::install() or restore(). If the requested version is in the cache, it is linked into the project library, and that is all. Otherwise the package is downloaded and installed into the project library, then copied into the cache and linked back. A project library entry is therefore a link into the cache, and two projects that use the same version of a package share one copy on disk. The script looks at the cache and at one of those links; the last two lines call renv::isolate(), which undoes the sharing for this project.

# The global cache: one directory per package, version and content hash.
cache <- renv::paths$cache()
cache
#> [1] "~/.cache/R/renv/cache/v5/<os>/R-4.6/<platform>"
list.files(cache)
#> [1] "jsonlite" "R6"       "tinytest"
list.files(file.path(cache, "R6"))
#> [1] "2.6.1"
list.files(file.path(cache, "R6", "2.6.1"))
#> [1] "d4335fe7207f1c01ab8c41762f5840d4"

# Project library entries are links into it, so two projects share one copy.
lib <- renv::paths$library()
link <- Sys.readlink(file.path(lib, "R6"))
startsWith(link, cache)
#> [1] TRUE
identical(normalizePath(link), normalizePath(file.path(lib, "R6")))
#> [1] TRUE

# Where renv keeps things, and how to move them.
Sys.getenv("RENV_PATHS_CACHE")
#> [1] "~/.cache/R/renv/cache"
renv::paths$root()
#> [1] "~/.cache/R/renv"
renv::settings$use.cache()
#> [1] TRUE
renv::config$cache.enabled()
#> [1] TRUE
renv::config$cache.symlinks()
#> [1] TRUE
renv::config$install.shortcuts()
#> [1] TRUE

# isolate() copies the linked packages into the project library.
renv::isolate()
#> - Copying packages into the private library ... Done!
#> - This project has been isolated from the cache.
nzchar(Sys.readlink(file.path(lib, c("R6", "jsonlite", "tinytest"))))
#> [1] FALSE FALSE FALSE

Four things in the output are worth reading. The cache path ends with the platform components that renv appends to whatever location is configured: a cache version (v5 for renv 1.3.1), a prefix that names the operating system, the R minor version (R-4.6) and the platform. A cache built for R 4.5 is a different directory, and two different Linux distributions do not collide. Below that is one directory per package, then per version, then per hash; the hash is the Hash of the package’s compact lockfile record, and the output of the lockfile script showed it to be the directory name. The project’s R6 is a symbolic link to that directory. And renv itself is not in the cache.

Where renv keeps things is controlled by environment variables. By default the root is ~/.cache/R/renv on Linux, ~/Library/Caches/org.R-project.R/R/renv on macOS and %LOCALAPPDATA%/R/cache/R/renv on Windows, and the cache is a directory below it; earlier versions used other folders (~/.local/share/renv on Linux), which renv still uses if it already exists there. renv::paths is a list of functions that return each location, and the variables are:

Variable Directory

RENV_PATHS_ROOT

The root of renv’s global state.

RENV_PATHS_CACHE

The package cache. Several caches can be listed, separated by ; (or : on Unix); renv looks in each in turn, and installs into the first writable one.

RENV_PATHS_LIBRARY

The project library; RENV_PATHS_LIBRARY_ROOT is the parent of project libraries.

RENV_PATHS_LOCKFILE

The lockfile.

RENV_PATHS_SOURCE, RENV_PATHS_BINARY

Downloaded package sources and binaries.

RENV_PATHS_CELLAR

The cellar: local tarballs of packages that no repository serves, found as <package>_<version>.tar.gz (sources) or .tgz (macOS binaries) or .zip (Windows binaries).

RENV_PATHS_PREFIX, RENV_PATHS_PREFIX_AUTO

A prefix added to library and cache paths to separate operating systems that share a cache. RENV_PATHS_PREFIX_AUTO=TRUE builds it from /etc/os-release; it is the default with renv 1.0.6 on R 4.4.0 or later, which is the <os> component above.

RENV_PATHS_VERSION

Replaces the R version component (renv 1.1.3); %V is the full version and %v the version without the patch level.

The paths reference adds two warnings. Set the variables in an R startup file so that they apply to every session: a project-local .Renviron, the user’s ~/.Renviron, or the site-wide Renviron.site, which is the right place for a cache shared by all users of an R installation (the startup order is on Sessions, Workspaces and Startup). And if reproducibility on a machine matters, back up and persist the cache: restoring from it is much easier than installing an old package from source again. A shared cache needs more care: the files in it must be readable by every user, access control lists included, and the RENV_CACHE_USER variable makes renv change the owner of cached packages. Two failure modes have their own tools. renv::purge("pkg") removes a corrupted package from the cache, and renv::restore(rebuild = TRUE), renv::install("pkg", rebuild = TRUE) or renv::rebuild() install again past it.

The cache can be turned off per project with renv::settings$use.cache(FALSE), or for a user with renv.config.cache.enabled. renv.config.cache.symlinks chooses between links and copies. It matters when the library and the cache are on different volumes, or when the project goes somewhere the links cannot follow, such as the image of a Docker build: renv.config.cache.symlinks = FALSE copies, so that nothing in the project library points outside it. renv::isolate(), which the script ran, copies the linked packages into the project library and leaves the cache in use for future installs. Finally, renv.config.install.shortcuts, on by default, lets renv install a local copy of a package it finds already installed in the user or site libraries, and renv::hydrate(), which init() calls, does the same job for a whole project: it copies the packages the project uses from the system libraries, then the cache, and installs from the repositories only what it does not find. That is why the init() run above needed no download.

Snapshot types

The default implicit snapshot records the packages that dependencies() finds, as seen above. Two others exist for people who prefer a different contract, and a fourth lets code decide. renv::settings$snapshot.type() reads the project’s type, and renv::settings$snapshot.type("explicit") changes it (it is saved in renv/settings.json); the type argument of snapshot() changes it for one call. The script asks for what each type would record, without writing the lockfile, by passing lockfile = NULL, and installs magrittr first, a package that nothing in the project uses, to make all show its difference.

# Install a package the project never uses (network: magrittr is not cached).
output <- capture.output(renv::install("magrittr"))
grep("^The following", output, value = TRUE)
#> [1] "The following package(s) will be installed:"

# What each snapshot type would record, without touching renv.lock.
recorded <- \(type, ...) {
  lock <- renv::snapshot(type = type, lockfile = NULL, prompt = FALSE, ...)
  sort(names(lock$Packages))
}
recorded("implicit")
#> [1] "jsonlite" "R6"       "renv"     "tinytest"
all_packages <- recorded("all")
length(all_packages)
#> [1] 20
setdiff(all_packages, recorded("implicit"))
#>  [1] "boot"       "class"      "cluster"    "codetools"  "foreign"
#>  [6] "KernSmooth" "lattice"    "magrittr"   "MASS"       "Matrix"
#> [11] "mgcv"       "nlme"       "nnet"       "rpart"      "spatial"
#> [16] "survival"

# "explicit" reads DESCRIPTION: here jsonlite is listed, but R6 is only used.
proj <- file.path(tempdir(), "described")
dir.create(proj)
writeLines(
  c("Package: described", "Version: 0.0.1", "Imports: jsonlite"),
  file.path(proj, "DESCRIPTION")
)
writeLines("library(R6)", file.path(proj, "analysis.R"))
lib <- renv::paths$library()
explicit <- renv::snapshot(
  project = proj, library = lib, type = "explicit",
  lockfile = NULL, prompt = FALSE
)
names(explicit$Packages)
#> [1] "jsonlite" "renv"
implicit <- renv::snapshot(
  project = proj, library = lib, type = "implicit",
  lockfile = NULL, prompt = FALSE
)
names(implicit$Packages)
#> [1] "R6"       "jsonlite" "renv"

# The type of this project is a setting stored in renv/settings.json.
renv::settings$snapshot.type()
#> [1] "implicit"
Type What the lockfile gets

implicit

The default: the packages that appear to be used according to dependencies(), plus their dependencies. Only packages the project needs enter the lockfile. The cost is time on projects with very many files; .renvignore files limit the scan.

explicit

Only the packages listed in the Depends and Imports fields of the project’s DESCRIPTION, plus their dependencies. It is the type to use when the project manages its dependencies directly, for a package or not. In the output, R6 is used by the script but not declared, so it is left out.

all

Every package in the active libraries. The quickest type, and the one that lets development tools into the lockfile. The output shows that in a project that exposes R’s recommended packages (boot, MASS, Matrix and so on) these come along too.

custom

Like implicit, with a filter: the renv.snapshot.filter option names a function ("package::method") or is one, takes the project directory, and returns the packages to include.

The help page also describes package projects, that is, projects with a DESCRIPTION at the top: renv records the package’s dependencies and not the package itself. Config/renv/snapshot/include-self: TRUE in the DESCRIPTION includes the package too (renv 1.2.4), for example for a Shiny application that is developed as a package and deployed by name. renv::init() prompts for a snapshot type when the project has a top-level DESCRIPTION (renv 1.0.0). The package structure is on Creating Packages.

Installing, updating and removing packages

After init() the usual commands keep working, because of the shims, and renv has its own. The introduction says that one of renv’s philosophies is that existing package-management habits should continue to work. renv::install() differs from install.packages() in that it uses the cache, knows several sources, and, when it is called with no arguments, installs every package the project requires, development dependencies included. It does not read the lockfile: it installs the latest versions available, unless a version is given. The package names can carry a source, in a subset of the syntax of the remotes package with one deviation, which is that a subdirectory is separated by a colon:

Request Installs

renv::install("pkg")

The latest version of pkg from the active repositories.

renv::install("pkg@1.2.3")

Version 1.2.3 of pkg, from the repository or from its archive.

renv::install("username/repo")

A package from GitHub; username/repo@ref takes a branch, tag or commit, and username/repo:subdir a subdirectory.

renv::install("bioc::pkg")

A package from Bioconductor; bioc::3.20/pkg names a Bioconductor version, and release and devel are accepted too.

renv::install("igraph=igraph/rigraph")

A remote whose package name cannot be read from the remote, supplied as a <package>= prefix.

The other arguments are include and exclude (restrict the packages of a project-wide install), library, type ("source" or "binary", default getOption("pkgType")), rebuild, repos, dependencies (which DESCRIPTION fields count; the default is the package.dependency.fields setting), transactional, and lock = TRUE, which updates renv.lock after the packages have installed. Since renv 1.3.0 the version constraints in the project’s own DESCRIPTION (dplyr (>= 1.1.0) in Imports) are honoured by install() and validated by snapshot(); and since 1.3.1 install() signals an error when a requested package fails to install, even if an older copy remains. The tinytest and magrittr installs above, and the R6 install below, went through this function.

renv::update() brings installed packages up to date. It looks only at the source a package was installed from, so a package installed from GitHub is not updated from CRAN. With check = TRUE it reports what is available without installing anything, which is what the script below does; with lock = TRUE it updates the lockfile afterwards; since renv 1.1.3 it looks only at the project library unless all = TRUE. It updates renv itself too (renv::upgrade() updates only renv, or installs a development version from GitHub). The recommended routine, from the introduction, is to run update(), then the project’s code, and then snapshot(); if the update breaks something, restore() goes back to the lockfile. renv::remove("pkg") removes a package from the project library; it asks for confirmation only when the target is not the project library (renv 1.2.4).

A few more functions are used less often, and the table says what each is for.

Function Purpose

renv::hydrate()

Copies packages from the system libraries and the cache into the project library. init() calls it, and the help page says it is rare to need it otherwise, since it can leave the project in an inconsistent state; hydrate(update = "all") refreshes the project’s packages to match the global library.

renv::rebuild()

Rebuilds and reinstalls packages in the library, as a diagnostic when packages fail to load.

renv::repair()

Recovers from some common problems with a project.

renv::clean()

Cleans up a project and its libraries.

renv::purge()

Removes a package from the cache.

renv::checkout()

Installs, or writes a lockfile for, the latest packages of a set of repositories; renv::checkout(date = "2023-02-08") uses the snapshot of Posit Public Package Manager for that date (renv 1.0.0).

renv::plan()

Resolves the packages a project needs and writes a lockfile without installing anything, which previews what restore() would do (renv 1.2.0).

renv::retrieve()

Downloads the packages of a project or a lockfile into the cache without installing them, so that a later step can install from the cache.

renv::sysreqs()

Reports the operating-system packages that a project’s R packages need (renv 1.1.2).

renv::vulns()

Asks a Posit Package Manager instance about known vulnerabilities in the packages of the lockfile (renv 1.1.6).

renv::run()

Runs an R script in a sub-process in the context of the project.

renv can also use the pak package to install packages. It is off by default (renv.config.pak.enabled); when it is on and pak is itself in the lockfile, restore() installs and uses that recorded version of pak (renv 1.3.1).

History and revert

A lockfile in version control is a history of the project’s dependencies. renv::history() uses git to list the commits that changed the lockfile, as a data frame with the columns commit, author_date, committer_date and subject; renv::revert(commit) checks the lockfile out of an older commit. Both are implemented for git only. revert() changes the lockfile and nothing else, and leaves it as an uncommitted modification, so it is followed by restore(), which makes the library match, and by a commit if the change is to be kept. The script sets up a repository with the commit dates fixed so that the output is the same on every run, puts an older R6 in the lockfile, and goes back. It installs R6 2.5.1 from the CRAN archive, which needs the network.

# Put the project under version control; renv/.gitignore keeps the library out.
Sys.setenv(
  GIT_AUTHOR_NAME = "Shelf Maintainer",
  GIT_AUTHOR_EMAIL = "maintainer@example.com",
  GIT_COMMITTER_NAME = "Shelf Maintainer",
  GIT_COMMITTER_EMAIL = "maintainer@example.com"
)
git <- \(...) system2("git", shQuote(c(...)), stdout = TRUE, stderr = TRUE)
git("init", "--quiet", "--initial-branch=main")
#> character(0)
git("add", ".")
#> character(0)
git("ls-files")
#> [1] ".Rprofile"          "renv.lock"          "renv/.gitignore"
#> [4] "renv/activate.R"    "renv/settings.json" "report.R"
Sys.setenv(
  GIT_AUTHOR_DATE = "2026-06-30T10:00:00",
  GIT_COMMITTER_DATE = "2026-06-30T10:00:00"
)
git("commit", "--quiet", "-m", "Record the project library")
#> character(0)

# Install an older R6 (network: it comes from the CRAN archive).
output <- capture.output(renv::install("R6@2.5.1"))
renv::status()
#> The following package(s) are out of sync [lockfile != library]:
#>
#> # CRAN -----------------------------------------------------------------------
#> - R6   [repo: RSPM != CRAN; ver: 2.6.1 != 2.5.1]
#>
#> See `?renv::status` for advice on resolving these issues.
renv::update("R6", check = TRUE)
#> - Checking for updated packages ... Done!
#> - 1 package has updates available.
#>
#> # CRAN -----------------------------------------------------------------------
#> - R6   [2.5.1 -> 2.6.1]
#>

# Keep the old version in the lockfile, and commit that.
renv::snapshot()
#> The following package(s) will be updated in the lockfile:
#>
#> # CRAN -----------------------------------------------------------------------
#> - R6   [repo: RSPM -> CRAN; ver: 2.6.1 -> 2.5.1]
#>
#> - Lockfile written to "~/project/renv.lock".
git("add", "renv.lock")
#> character(0)
Sys.setenv(
  GIT_AUTHOR_DATE = "2026-07-01T10:00:00",
  GIT_COMMITTER_DATE = "2026-07-01T10:00:00"
)
git("commit", "--quiet", "-m", "Pin R6 2.5.1")
#> character(0)

# history() lists the commits that changed the lockfile.
changes <- renv::history()
commits <- changes$commit
changes$commit <- substr(changes$commit, 1, 7)
changes[c("commit", "subject")]
#>    commit                    subject
#> 1 effa8f7               Pin R6 2.5.1
#> 2 b3c9691 Record the project library

# revert() checks the lockfile out of the older commit and then runs `git diff`
# on it, which writes straight to the terminal; an external diff program that
# prints nothing keeps that out of the way here. restore() follows the lockfile.
Sys.setenv(GIT_EXTERNAL_DIFF = "true")
renv::revert(commit = commits[2])
#> - renv.lock from commit b3c96913e51eeede2035c31214e499551783f35d has been checked out.
renv::status()
#> The following package(s) are out of sync [lockfile != library]:
#>
#> # CRAN -----------------------------------------------------------------------
#> - R6   [repo: RSPM != CRAN; ver: 2.6.1 != 2.5.1]
#>
#> See `?renv::status` for advice on resolving these issues.
output <- capture.output(renv::restore())
linked <- grep("linked", output, value = TRUE)
cat(gsub("\033\\[[0-9;?]*[A-Za-z]", "", linked), sep = "\n")
#> ✔ R6 2.6.1                                 [linked from cache]
git("status", "--short")
#> [1] " M renv.lock"
git("commit", "--quiet", "-am", "Go back to R6 2.6.1")
#> character(0)

The output has the whole story. git ls-files shows what a project tracks: .Rprofile, renv.lock, renv/activate.R, renv/settings.json, renv/.gitignore and the code, and nothing of the library. status() reports the R6 mismatch in two parts at once: the version (2.6.1 against 2.5.1) and the repository (RSPM against CRAN), because 2.5.1 came from the CRAN archive and its DESCRIPTION says CRAN. update(check = TRUE) reports that 2.6.1 is available. After snapshot() the lockfile pins 2.5.1; history() lists the two commits; revert() restores the earlier lockfile; status() sees the library and the lockfile disagree again in the other direction; and restore() reinstalls 2.6.1 from the cache. The comment in the script explains one oddity: revert() ends by running git diff on the lockfile and the diff goes straight to the terminal, bypassing R’s output; the script points git at an external diff program that prints nothing, so that the diff does not interrupt the transcript. In an ordinary terminal that diff is part of what revert() shows.

Working together: a fresh checkout

The point of the lockfile is the other computer. The commands below clone the committed project, start R in the clone with an empty package cache, so that nothing is available locally, and restore. This stands for a colleague’s first day, or for a build server. It needs the network: renv bootstraps itself, and the packages are downloaded and built from source.

git clone project clone
cd clone
ls -A
RENV_PATHS_CACHE=~/other-cache Rscript -e 'renv::restore()'
Rscript -e 'renv::status()'
.git
renv
renv.lock
report.R
.Rprofile
# Bootstrapping renv 1.3.1 ---------------------------------------------------
- Downloading renv ... OK
- Installing renv  ... OK

- None of the packages recorded in the lockfile are currently installed.
- Use `renv::restore()` to restore the project library.
The following package(s) will be updated:

# CRAN -----------------------------------------------------------------------
- tinytest   [* -> 1.4.3]

# RSPM -----------------------------------------------------------------------
- jsonlite   [* -> 2.0.0]
- R6         [* -> 2.6.1]

# Downloading packages -------------------------------------------------------
✔ R6 2.6.1                                 [65 kB]
✔ jsonlite 2.0.0                           [1.1 MB]
✔ tinytest 1.4.3                           [596 kB]

# Installing packages --------------------------------------------------------
✔ R6 2.6.1                                 [built from source]
✔ tinytest 1.4.3                           [built from source]
✔ jsonlite 2.0.0                           [built from source]
No issues found -- the project is in a consistent state.

The first lines of the output are the listing of the clone (the .git directory, renv/, renv.lock, report.R and .Rprofile; the directory order depends on the locale). The first Rscript ran .Rprofile, which ran renv/activate.R; the autoloader did not find renv 1.3.1 and bootstrapped it, that is, downloaded and installed it. The renv documentation says that renv does not have to be installed beforehand. It then reported the state of the project: none of the recorded packages is installed. restore() printed the plan, grouped by the repository each record names, downloaded the three packages, and built them (the packages are downloaded in parallel, so the order of the lines in the download group can differ from run to run). The status() that follows finds no issues.

The renv introduction states what to commit: renv.lock, .Rprofile, renv/settings.json and renv/activate.R, together with renv/.gitignore, which renv creates so that a plain git add . takes the right files. A collaborator who opens the project then gets renv bootstrapped, and renv offers to run restore(). If the project is returned to after a long time, the FAQ separates two intents: a "time capsule" is a restore() of the lockfile, possibly with the old version of R installed again, and a project with fluid dependencies starts with update(), then the code, then snapshot().

When the project is not activated

Everything above relies on the project being active. A session that does not load the project is a common source of confusing results, and renv can only warn the people who are there to read the warning, the interactive users. The next script is run as Rscript --no-init-file, which skips the user profile, so the .Rprofile of the project is not read; R_PROFILE_USER set to another file, or a launcher that starts R in a different directory, would do the same. Nothing is activated, renv::project() is NULL, and renv sees whichever libraries the machine has. The script calls snapshot() with a different lockfile path, so that the project’s lockfile is not touched.

Rscript --no-init-file not-activated.R
# Run with `Rscript --no-init-file`: the project's .Rprofile is not read, so the
# project is not activated and renv sees the machine's libraries, not its own.
renv::project()
#> NULL
file.exists("renv.lock")
#> [1] TRUE

# snapshot() skips what it cannot find in the current library paths, and
# goes on; the result is a lockfile that has silently lost a package.
other <- tempfile(fileext = ".lock")
output <- capture.output(renv::snapshot(lockfile = other, prompt = FALSE))
cat(output[1:4], sep = "\n")
#> The following required packages are not installed:
#> - tinytest
#> Packages must first be installed before renv can snapshot them.
#> Use `renv::dependencies()` to see where this package is used in your project.
names(renv::lockfile_read("renv.lock")$Packages)
#> [1] "R6"       "jsonlite" "renv"     "tinytest"
names(renv::lockfile_read(other)$Packages)
#> [1] "R6"       "jsonlite" "renv"

# Interactive sessions get a prompt instead; its wording in renv's source:
prompt <- deparse(renv:::renv_activate_prompt_impl)
cat(trimws(grep("hasn't been|\\.\"", prompt, value = TRUE)), sep = "\n")
#> fmt <- "It looks like you've called renv::%s() in a project that hasn't been activated yet."
#> choices <- c(activate = "Activate the project and use the project library.",
#> continue = "Do not activate the project and use the current library paths.",
#> cancel = "Cancel and resolve the situation another way.")

The lockfile that comes out has lost tinytest, because tinytest is installed in the project library and the session looks in the machine’s. Nothing stopped the snapshot: renv names the missing package and goes on, and with prompt = FALSE there is nobody to ask. Packages that do exist in the machine’s libraries would be recorded at the machine’s versions, not at the project’s. In an interactive session renv does ask: the activate.prompt config option (on by default) makes snapshot() and restore(), in a project that has not been activated, offer to activate it, to continue with the current library paths, or to cancel. The last lines of the script print its wording from renv’s source, with an internal function, to show it; the function name is not part of the interface.

Scripts that carry their own packages: use() and embed()

A project is the right unit for an analysis with many files, and the wrong one for a script that is sent by itself. renv::use() is renv’s lightweight answer: it describes the packages in the script and their versions, builds a library for the script in R’s temporary directory, installs the requested packages and their recursive dependencies into it, and puts it on the library paths for the rest of the session. By default isolate = TRUE, so only the requested packages are visible besides R’s own, attach = TRUE would also attach them with library(), and lockfile = "path/to/renv.lock" takes the versions from a lockfile, which suits a blog where each post keeps the lockfile of the day it was written. repos = NULL installs only from the cache (renv 1.2.0). The documentation notes that use() is inspired in part by the groundhog package. The script below runs without a project; R6 2.6.1 is in the cache, so the package is linked and not downloaded.

Rscript --no-init-file use.R
# Run with `Rscript --no-init-file` in a directory that is not an renv project:
# use() builds a temporary library for this one script.
renv::project()
#> NULL
output <- capture.output(renv::use("R6@2.6.1"))
packageVersion("R6")
#> [1] ‘2.6.1’
startsWith(basename(.libPaths()[1]), "renv-use-libpath")
#> [1] TRUE

renv::embed() writes the call for you. Given a script, an R Markdown file or a Quarto document, it inspects the dependencies, finds the versions in the project’s lockfile (or from the installed packages, with lockfile = FALSE, or from the repositories, with lockfile = NA), and inserts a renv::use() call at the top. Only packages that are in the lockfile are included: renv 1.1.6 made embed() stop when a dependency is not available, and 1.2.0 made it warn when a dependency is missing from the lockfile. The call in the output also lists renv itself. The script below works on a file in the temporary directory, because embed() changes the file.

# embed() writes a renv::use() call into a script, from the lockfile.
script <- file.path(tempdir(), "one-off.R")
writeLines(c(
  "library(R6)",
  "Counter <- R6Class(\"Counter\", public = list(n = 0L))",
  "Counter$new()$n"
), script)
invisible(renv::embed(script))
cat(readLines(script), sep = "\n")
#> renv::use(
#>   R6   = "R6@2.6.1",
#>   renv = "renv@1.3.1"
#> )
#>
#> library(R6)
#> Counter <- R6Class("Counter", public = list(n = 0L))
#> Counter$new()$n

Settings and config

Two mechanisms change how renv behaves. Settings belong to the project, are written to renv/settings.json and travel with the code; config options belong to the user or the machine. The settings are read and written with a function per setting, renv::settings$snapshot.type() to read and renv::settings$snapshot.type("explicit") to write; the value is saved straight away (persist = FALSE keeps a change to the session), and a hand edit of the file needs a restart of R. The defaults for new projects come from options(renv.settings = list(snapshot.type = "all")) or options(renv.settings.snapshot.type = "all"), typically in a user’s ~/.Rprofile. Config options are read with renv::config$name(). For a given name, an R option renv.config.<name> wins; otherwise the environment variable RENV_CONFIG_<NAME> is used, with periods turned into underscores; otherwise the default applies. The environment variable is inherited by child processes and the R option is not, which is the reason to pick one over the other. Config is best set in ~/.Renviron or the site’s Renviron.site; in a project .Rprofile it must come before source("renv/activate.R").

# Settings belong to the project and live in renv/settings.json.
names(renv::settings)[1:6]
#> [1] "bioconductor.enabled"      "bioconductor.version"
#> [3] "lockfile.sanitize"         "ignored.packages"
#> [5] "external.libraries"        "package.dependency.fields"
renv::settings$snapshot.type()
#> [1] "implicit"
renv::settings$package.dependency.fields()
#> [1] "Imports"   "Depends"   "LinkingTo"
renv::settings$use.cache()
#> [1] TRUE

# Config options belong to the user: the default, then an environment
# variable, then an R option, which wins over the variable.
renv::config$auto.snapshot()
#> [1] FALSE
Sys.setenv(RENV_CONFIG_AUTO_SNAPSHOT = "TRUE")
renv::config$auto.snapshot()
#> [1] TRUE
options(renv.config.auto.snapshot = FALSE)
renv::config$auto.snapshot()
#> [1] FALSE
options(renv.config.auto.snapshot = NULL)
Sys.unsetenv("RENV_CONFIG_AUTO_SNAPSHOT")
renv::config$auto.snapshot()
#> [1] FALSE

The settings that matter most, with their defaults:

  • snapshot.type ("implicit"): The default snapshot type: "implicit", "explicit", "all" or "custom".

  • snapshot.dev (FALSE): Whether snapshot() and status() include development dependencies (renv 1.1.6).

  • ignored.packages (none): Packages snapshot() leaves out; a package already in the lockfile stays.

  • package.dependency.fields (Imports, Depends, LinkingTo): DESCRIPTION fields that decide which dependencies install() installs; adding Suggests installs suggested packages as well.

  • r.version (none): The R version to write in the lockfile; renv still warns when the major and minor version of the running R differ.

  • use.cache (TRUE): Whether the project uses the global cache.

  • external.libraries (none): Additional library paths visible to the project. Risky: a different version in an external library can break isolation.

  • vcs.manage.ignores, vcs.ignore.library (TRUE): Let renv manage the version-control ignore files, and keep the library out of version control.

  • lockfile.sanitize (TRUE): Strip credentials from repository URLs in the lockfile.

  • ppm.enabled (unset): Whether renv rewrites Posit Package Manager source URLs to binary URLs for the platform.

And a selection of config options, with their defaults:

  • auto.snapshot (FALSE): Snapshot automatically when the project’s dependencies change.

  • autoloader.enabled (TRUE): Whether activate.R loads the project at startup.

  • cache.enabled, cache.symlinks (TRUE; unset means the platform default): Use the cache; link to it rather than copy.

  • install.shortcuts (TRUE): Install a local copy of a package found in the user or site library.

  • install.jobs (4): Number of packages installed in parallel.

  • install.transactional (TRUE): Install into a temporary library and move the packages in only if all succeed.

  • pak.enabled (FALSE): Install with pak.

  • repos.override (none): Override the repositories during restore(); a single URL or NAME=URL pairs separated by ;. Useful in CI.

  • sandbox.enabled (TRUE): Hide non-base packages of the system library from the project.

  • shims.enabled (TRUE): Route install.packages(), update.packages() and remove.packages() to renv.

  • startup.quiet (unset): Suppress the "Project loaded" banner.

  • synchronized.check (TRUE): Check on load that the library and the lockfile agree.

  • sysreqs.check (TRUE): Check that the system libraries a package needs are installed, using the r-system-requirements database.

  • updates.check (FALSE): Check for package updates at startup.

  • user.profile (FALSE): Also run the user’s ~/.Rprofile on load; off, because arbitrary code there could break the project’s encapsulation.

Other documented controls exist for downloads: renv uses curl when it can, which supports custom headers, timeouts and retries; the renv.download.override option substitutes R’s own download.file(), the RENV_DOWNLOAD_METHOD variable selects the method, proxies are set with http_proxy and https_proxy or a .curlrc, credentials for private remotes come from GITHUB_PAT, GITLAB_PAT, BITBUCKET_USER and BITBUCKET_PASSWORD or GIT_PAT, GIT_USER and GIT_PASSWORD, and renv.download.trace = TRUE prints raw requests while debugging. Keep such secrets in .Renviron, outside the repository.

renv in continuous integration

The recipe in the CI article has three steps: take renv.lock from version control (it was made on a developer’s computer by snapshot()), renv::restore() on the CI service, and cache both the project library and the global cache between runs. The article notes that this is generally not a good fit for CRAN packages, whose checks should see the latest versions of the dependencies. For GitHub Actions the r-lib organisation provides an action, setup-renv, that installs renv, configures the GitHub cache, activates an optional profile, and runs renv::restore(); after it, steps that run R use the project’s library by default.

steps:
- uses: actions/checkout@v5
- uses: r-lib/actions/setup-r@v2
- uses: r-lib/actions/setup-renv@v2

The same article shows the do-it-yourself version, which uses actions/cache keyed on a hash of the lockfile so that the cache is renewed exactly when the lockfile changes:

env:
  RENV_PATHS_ROOT: ~/.cache/R/renv

steps:

- name: Cache packages
  uses: actions/cache@v4
  with:
    path: ${{ env.RENV_PATHS_ROOT }}
    key: ${{ runner.os }}-renv-${{ hashFiles('**/renv.lock') }}
    restore-keys: |
      ${{ runner.os }}-renv-

- name: Restore packages
  shell: Rscript {0}
  run: |
    if (!requireNamespace("renv", quietly = TRUE)) install.packages("renv")
    renv::restore()

Both are fragments that were not run here. The article gives a GitLab CI template in the same spirit, with RENV_PATHS_ROOT set below the project directory and cached. Two newer functions help in pipelines: renv::plan() writes a lockfile from repository metadata without installing, which suits a lightweight container without compilers, and renv::retrieve() downloads packages in one stage so that a later stage installs from the cache. Testing a package in CI is on Testing with testthat, and the Docker pages cover the general side of Docker CI/CD with GitHub Actions.

renv in Docker

A container fixes what renv cannot: the operating system, the system libraries, the compilers and the BLAS. The renv Docker article describes the most common case, an existing renv project that is to be built into an image, and the recommended structure is to copy the renv metadata first, restore, and only then copy the rest of the project, so that Docker’s layer cache is reused whenever the code changes and the lockfile does not (the same ordering rule as on Dockerfile Fundamentals and Build Cache and Multi-Stage Builds). The article uses a placeholder for the parent image and recommends a version-tagged one. Here it is rocker/r-ver:4.6.1, the rocker project’s image for R 4.6.1, which is described in the section on pinning R.

FROM rocker/r-ver:4.6.1

# initialize application project directory
WORKDIR /project
RUN mkdir -p renv

# copy renv infrastructure
COPY renv.lock renv.lock
COPY .Rprofile .Rprofile
COPY renv/activate.R renv/activate.R
COPY renv/settings.json renv/settings.json

# restore R project library
RUN R -s -e "renv::restore()"

# copy application files into image
COPY . .
CMD ["Rscript", "report.R"]

The article’s fragment ends at COPY . .; the CMD line is added here. R -s -e runs R with the input not echoed and executes the expression, then exits (R --help shows both options). renv does not have to be installed in the parent image: R sources .Rprofile, which sources renv/activate.R, which downloads and installs renv. A .dockerignore keeps the host’s library, sandbox and other working directories out of the build context, and lets through the two files the Dockerfile copies:

renv/*
!renv/activate.R
!renv/settings.json

RENV_PATHS_LIBRARY before the restore places the library somewhere else, such as /renv/library. To avoid downloading and building packages again when the restore layer is rebuilt, the article describes a BuildKit cache mount. The RUN --mount=type=cache line makes a persistent build cache available at the cache path during the instruction. The symlink setting is the detail that is easy to miss: the mount does not exist in the final image, so links into it would be broken there, and RENV_CONFIG_CACHE_SYMLINKS=FALSE makes renv copy.

# syntax=docker/dockerfile:1
FROM rocker/r-ver:4.6.1

WORKDIR /project
RUN mkdir -p renv

COPY renv.lock renv.lock
COPY .Rprofile .Rprofile
COPY renv/activate.R renv/activate.R
COPY renv/settings.json renv/settings.json

# set path to the renv package cache
ENV RENV_PATHS_CACHE=/renv/cache

# ensure packages are copied, not symlinked
ENV RENV_CONFIG_CACHE_SYMLINKS=FALSE

# restore with mounted cache
RUN --mount=type=cache,target=/renv/cache \
    R -s -e "renv::restore()"

COPY . .

That cache helps on the machine or builder that holds it and, as the article says, will not usually carry over to a fresh CI runner; for that case it describes a bind-mounted host cache restored by the CI platform and given to docker buildx build with --build-context renv-cache=.cache/renv. Compiled packages need system libraries before the restore. renv::sysreqs(distro = "ubuntu:24.04", report = TRUE, collapse = TRUE) reports the apt-get command for the packages in the lockfile (since renv 1.3.1 it works from renv.lock alone, even before any R package is installed), and it should be run again whenever the lockfile changes, since requirements belong to a package version. A multi-stage build keeps compilers out of the final image. The Docker side of this is on Images, Layers and Tags and Docker.

Pinning R itself

The lockfile records the version of R; renv cannot select it. renv::status() compares the two, and the script shows the message by pointing it at a copy of the lockfile that claims R 4.5.2. The r.version setting is the knob for projects where several people use different R versions, and the load-time check warns when the major and minor version differ. This status message appeared in renv 1.0.4. What snapshot() does is overwrite the recorded version with the running one, so changing R and snapshotting is the way to declare a deliberate upgrade.

# The lockfile records the R version it was written with ...
lock <- renv::lockfile_read()
lock$R$Version
#> [1] "4.6.1"
getRversion()
#> [1] ‘4.6.1’

# ... and status() compares it with the R that is running. Here a copy of
# the lockfile claims to come from R 4.5.2.
lock$R$Version <- "4.5.2"
other <- tempfile(fileext = ".lock")
invisible(capture.output(renv::lockfile_write(lock, other)))
renv::status(lockfile = other)
#> The lockfile was generated with R 4.5.2, but you're using R 4.6.1.
#>
#> See `?renv::status` for advice on resolving these issues.

# The r.version setting decouples the recorded version from the running one.
renv::settings$r.version()
#> NULL

To choose the R version on a computer, the renv documentation points at tools such as rig. rig is a community tool, a standalone program for macOS, Windows and Linux that installs and manages several versions of R side by side and switches the default for the terminal and the IDEs; its version at the time of writing is 0.11.0. The commands below install R 4.6.1, make it the default and list the versions. They are a fragment that was not run here, and rig is not an official R tool. rig is also described as able to import and export an renv.lock in its experimental rig proj feature.

rig add 4.6.1
rig default 4.6.1
rig list

For a reproducible machine rather than a reproducible R, the usual answer is a container. The rocker project’s rocker/r-ver images are described as an alternative to the Debian-based r-base image with an emphasis on reproducibility. Its page lists what that means. The images build on an LTS release of a Linux distribution chosen when the R version was released; they install a fixed version of R from source rather than whatever the distribution packages; and they set Posit Public Package Manager as the default CRAN mirror, which serves compiled Linux binaries for the amd64 platform. And, for versions of R that are not the latest, they install all R packages from a fixed snapshot of CRAN mirror taken at a given date, which the page says makes the same version of a package install whenever the installation happens. So rocker/r-ver:4.6.1 pins R and the system layer, and, once a newer R exists, the CRAN snapshot that plain install.packages() uses by default. The 4.6.1 image used for checking this page reported R 4.6.1 and had the package manager’s latest address as its default repository, not a dated snapshot, which fits the statement that only the images that are not the latest are frozen; the page does not list the snapshot date of each version. The same page shows how to change the mirror in an image by writing a new options(repos = ...) line into Rprofile.site. Combined with renv, the two layers pin different things: the base image fixes R and system libraries, and the lockfile fixes the packages, whatever the mirror says. The Dockerfile earlier on this page does exactly that: the tag in its FROM line is the pin of R.

Packrat and migration

Before renv there was packrat, which also gave a project a private library and a snapshot, with a packrat/ directory and a packrat.lock file. Its repository now says that packrat has been soft-deprecated and is superseded by renv, that it will continue to be maintained while all new development focuses on renv, and that renv::migrate() migrates a project from packrat to renv. migrate() can move the lockfile (packrat/packrat.lock to renv.lock), the sources, the library, compatible options and the cache; the packrat argument selects the components. A project that still uses packrat is a candidate for renv::migrate() followed by renv::status(). The rest of this page applies to the result. The block below is a fragment that was not run, because there is no packrat project in the examples to migrate.

renv::migrate()
renv::status()

Common messages and errors

The messages that renv prints are part of its interface, and most are a call to action. This script collects the ones that have not appeared yet: a restore in a directory that has no lockfile, a status in one that never used renv, a lockfile that is not JSON, and an install of a package that does not exist (this one queries the repository, so it needs the network). The error from install() is a condition that can be caught, and its message lists the failures.

# A directory that never saw renv.
empty <- file.path(tempdir(), "empty")
dir.create(empty)
tryCatch(renv::restore(project = empty), error = \(e) conditionMessage(e))
#> [1] "This project does not contain a lockfile.\nHave you called `snapshot()` yet?"
renv::status(project = empty)
#> This project does not appear to be using renv.
#> Use `renv::init()` to initialize the project.

# A lockfile that is not JSON.
bad <- tempfile(fileext = ".lock")
writeLines("{ not json", bad)
tryCatch(renv::lockfile_read(bad), error = \(e) conditionMessage(e))
#> [1] "Failed to parse 'renv.lock':\n3:14: unexpected symbol\n2:         object( \n3:          not json\n                ^"

# A package that does not exist (network: the repository is queried first).
output <- capture.output(
  res <- tryCatch(
    renv::install("no.such.pkg.xyz"),
    error = \(e) conditionMessage(e)
  )
)
grep("not available", output, value = TRUE)
#> [1] "- [no.such.pkg.xyz]: package 'no.such.pkg.xyz' is not available"
res
#> [1] "package installation failed\n- no.such.pkg.xyz: package 'no.such.pkg.xyz' is not available"

Here are the other messages of this page in one place, with the usual remedy. The first group is printed by status().

No issues found -- the project is in a consistent state.
The following package(s) are used in this project, but are not installed:
The following package(s) are in an inconsistent state:
The following package(s) are out of sync [lockfile != library]:
The lockfile was generated with R 4.5.2, but you're using R 4.6.1.
This project does not appear to be using renv.

The first line is the goal. The second asks for restore() (recorded) or install() (not recorded). The third and fourth ask for snapshot() or restore(), depending on which side is right. The fifth asks for the right R, or for a snapshot to record the R in use. The sixth asks for init(). The second group belongs to snapshot() and restore():

The following package(s) will be updated in the lockfile:
- Lockfile written to "~/project/renv.lock".
- The lockfile is already up to date.
The following package(s) will be updated:
- The library is already synchronized with the lockfile.
This project does not contain a lockfile.
The following required packages are not installed:
Packages must first be installed before renv can snapshot them.
package installation failed

The messages with "will be updated in the lockfile" list package, old and new version in the form [* -> 2.0.0], and the ones that are "already" something say there is nothing to do. The remaining two are failures that stop the work: "Failed to parse 'renv.lock'" followed by the position, which means the file was damaged, for example by a bad merge, so restore it from version control; and "package installation failed", which lists the packages that did not install, usually with a reason such as "is not available". When a restore fails because a package from an old lockfile no longer builds, restore(retry = TRUE) tries the latest version of the failing packages.

Turning renv off

The introduction describes two levels. renv::deactivate() removes the autoloader line from .Rprofile and leaves the other renv files alone, so that renv::activate() brings it back; renv::deactivate(clean = TRUE) removes the lockfile and the renv/ directory too, leaving the project as it was found. In an interactive session both restart R; in a script the change is for the next session. The script below deactivates the project and activates it again. It is the last script, because after deactivate() the project is not active in this session.

# deactivate() removes the autoloader from .Rprofile; renv.lock and renv/ stay.
readLines(".Rprofile")
#> [1] "source(\"renv/activate.R\")"
renv::deactivate()
file.exists(".Rprofile")
#> [1] FALSE
file.exists("renv.lock")
#> [1] TRUE

# activate() writes it again; it takes effect in the next R session.
renv::activate()
readLines(".Rprofile")
#> [1] "source(\"renv/activate.R\")"

To stop using renv for all projects, delete renv’s global state, which is the root directory given by renv::paths$root(), and then remove the package. The block below is a fragment that was not run, and it is destructive: it deletes the cache of every renv project on the machine.

root <- renv::paths$root()
unlink(root, recursive = TRUE)
utils::remove.packages("renv")

The environment variable RENV_CONFIG_AUTOLOADER_ENABLED=FALSE is the way to turn the autoloader off for one session without changing any file.

Version notes

The renv NEWS file lists every release. The ones that changed what this page describes follow, each with the CRAN release date given in the NEWS.

  • renv 0.8.0 (2019-10-25): "Initial CRAN release."

  • renv 0.17.1 (2023-03-16): Project settings are stored in renv/settings.json instead of renv/settings.dcf; old settings are migrated automatically.

  • renv 1.0.0 (2023-07-07): Added renv::checkout(). deactivate(clean = TRUE). init() uses Posit Public Package Manager by default when the repositories have not been configured. The lockfile_create(), lockfile_read(), lockfile_write() and lockfile_modify() family. snapshot() and status() no longer track development dependencies, while install() still installs them. MRAN integration disabled by default. restore() can use pak (opt-in). init() prompts for a snapshot type when a DESCRIPTION exists.

  • renv 1.0.4 (2024-02-21): status() reports when the R version in the lockfile differs from the running one. Historical binaries come from Posit Public Package Manager instead of MRAN, described as now defunct. init() respects Remotes:.

  • renv 1.0.6 (2024-04-11): With R 4.4.0 or later, the operating-system prefix of library and cache paths is generated by default (the paths reference).

  • renv 1.0.8 (2024-09-20): renv::lockfile_validate() checks a lockfile against a schema.

  • renv 1.1.0 (2025-01-29): Lockfile records include more DESCRIPTION fields; options(renv.lockfile.version = 1) writes the older compact format.

  • renv 1.1.2 (2025-03-02): renv::sysreqs() and, on Linux, a check of the system libraries a package needs.

  • renv 1.1.3 (2025-03-18): update() looks only at the project library by default (all = TRUE for the rest); RENV_PATHS_VERSION; install() gains transactional; profile comments in ignore files.

  • renv 1.1.6 (2026-01-16): The snapshot.dev setting; renv::vulns(); RENV_CONFIG_REPOS_OVERRIDE takes NAME=URL pairs; RENV_LOCKFILE_VERSION=1.

  • renv 1.2.0 (2026-03-25): Parallel downloads and installs (install.jobs, default 4); Posit Package Manager enabled by default on arm64 Linux; renv::plan(); the strict argument of restore(); credentials stripped from repository URLs in the lockfile; the crandb.enabled option.

  • renv 1.2.4 (2026-08-03): Config/renv/snapshot/include-self in a package DESCRIPTION; remove() gains prompt.

  • renv 1.3.0 (2026-09-29): Version constraints in the project’s DESCRIPTION are honoured by install() and validated by snapshot(); git lockfile records are quoted and checked before use; restore(retry = TRUE) keeps the packages that installed in the first pass; dependencies() follows Quarto’s engine-binding rules.

  • renv 1.3.1 (2026-10-06): sysreqs() covers recursive dependencies and works from the lockfile; install() signals an error when a requested package fails to install; install() and restore() check the cache again just before building each package; a warning when a retrieved package is not the one requested.

The 1.0.0 release is the first one numbered 1.x, after the 0.x releases that began with the initial CRAN release, and the 1.x line is the one all of this page applies to. The behaviour of 1.1, 1.2 and 1.3 is mostly additive; the largest visible change is the lockfile format of 1.1.0. renv needs no particular release of R for what this page shows; the one R-version fact is that the automatic operating-system prefix of library and cache paths is the default from R 4.4.0 on.

Where older tutorials differ

Material about renv written before 2023, and material about its predecessors, differs from the current tool in the following ways.

Older advice Today

Start with renv::init(bare = TRUE) and install everything again.

renv::init() discovers the packages the project uses and links them from the libraries that exist (hydrate()), so a new project starts working at once. bare = TRUE is for an empty library on purpose.

Use packrat (packrat::init(), packrat/, packrat.lock).

Packrat is soft-deprecated and superseded by renv; renv::migrate() moves a project.

Freeze the date with the checkpoint package, which relied on MRAN snapshots (community material).

Microsoft’s MRAN service was shut down; the renv NEWS first says its shutdown is upcoming (1.0.0) and later calls it defunct (1.0.4). Use the lockfile; to choose a date, renv::checkout(date = ...) takes a Posit Public Package Manager snapshot, and a dated Posit Package Manager repository in repos does the same.

A lockfile entry looks like "Hash": "..." and nothing else.

Since renv 1.1.0 the record holds the installed package’s DESCRIPTION fields; the compact form needs options(renv.lockfile.version = 1).

Settings live in renv/settings.dcf.

They live in renv/settings.json (since renv 0.17.1).

Call install.packages() inside the analysis script so that it "just works" on another machine.

A script should not install packages: declare them in the project and let restore(), or renv::use() for a single file, do it. Interactive install.packages() is fine in an renv project, since it is routed to renv.

Put .libPaths(c("renv/library", .libPaths())) or R_LIBS in .Rprofile for isolation.

The autoloader sets the library paths, hides the user and site libraries, and checks the lockfile; leaving the others visible breaks isolation.

renv pins everything.

It records the R version and warns about differences, but does not choose it: pin R with rig or the rocker images, and the system layer with a container.

A project is reproducible if the lockfile is.

The documentation lists what is not covered: R, pandoc, the operating system and its libraries, compilers and the availability of old binaries.

Where to go next