In order to exactly reproduce a given result, it may be necessary to use programs in the exact versions used originally.
Sandve, Nekrutenko, Taylor, and Hovig, Ten Simple Rules for Reproducible Computational Research, Rule 3 (2013)
6.1 Learning objectives
By the end of this chapter the reader should be able to:
Explain the package-churn failure mode and why pinning package versions is the step that lifts an analysis from locatable (L0) to package-pinned (L1).
Describe what an renv.lock file records, and carry out the init, snapshot, and restore workflow that produces and consumes it.
State the dependency-triad invariant relating the code, the DESCRIPTION, and the lockfile, and run the automated check that keeps the three in agreement.
Configure a dated snapshot of a package repository so that a restore draws fixed, pre-compiled binaries.
Delimit what renv does and does not pin, and thus explain why a lockfile alone cannot reach the pinned-environment level.
Compare renv with the lighter, date-based alternatives rang, groundhog, and capsule.
6.2 Orientation
Consider a familiar situation. A doctoral student completes the analysis for a registry study of hospital readmissions, renders a clean report, and deposits the code alongside the manuscript. The work is under version control, so a reader can locate every script; on the ladder of Chapter 3 the analysis has reached L0, locatable. Eighteen months later a reviewer asks for a minor revision. The student pulls the repository onto a new laptop, installs the packages afresh, and reruns the analysis. The model now reports a different coefficient. Nothing in the code changed. What changed is that dplyr had moved from version 1.1.0 to 1.2.1, a grouping default had been refined, and one intermediate summary was computed over a slightly different partition. The number in the published table can no longer be regenerated, and the student cannot say with certainty which number was correct.
This is the package-churn failure mode, and it is the specifically computational failure that the empirical studies of Chapter 2 isolate. The R package ecosystem is healthy precisely because it changes: maintainers fix bugs, sharpen defaults, and deprecate awkward interfaces. Every such improvement is, however, a potential threat to the exact reproduction of an analysis written against the older behavior. A script names the packages it loads but almost never the versions it loaded them at, and so it inherits whatever versions the next machine happens to provide. Reproducibility fails in the gap between the packages the analysis was written against and the packages a later installation supplies.
The remedy for this one layer is a lockfile: a file that records the exact version of every package the analysis used, so that the same set can be reinstalled later. Recording that file is the transition from L0 to L1, from locatable to package-pinned, and it is the whole business of this chapter. We shall be equally careful about the transition’s limit. A lockfile pins the packages and nothing beneath them, so an analysis at L1 is pinned at the package layer and exposed at every layer below, a boundary that motivates the container of Chapter 7.
6.3 The analyst’s contribution
The tooling in this chapter automates a great deal, but three judgments remain irreducibly the analyst’s.
Deciding when to snapshot, and what a snapshot claims. A lockfile is a dated assertion that these versions produced this result. The analyst chooses the moment to make that assertion, typically once an analysis is stable, and must understand that a snapshot taken mid-exploration pins whatever half-installed state the library happens to be in. The tool records; the analyst decides what is worth recording.
Distinguishing a result dependency from a development tool. No scanner can know that languageserver powers the editor while dplyr powers the analysis. The analyst must keep the lockfile an honest, minimal record of what determines the result, and keep mere tooling out of it, a distinction we develop below and that the accompanying whitepaper treats at length.
Matching the reproducibility claim to the layer actually pinned. A lockfile secures the package layer and no other. Presenting a lockfile-equipped analysis as fully reproducible, when the R version, the operating system, and the system libraries remain unpinned, overstates what has been achieved. The honesty is the analyst’s responsibility, not the tool’s.
NoteThe vocabulary of this chapter
Lockfile. A machine-generated file, renv.lock, recording the exact version, source repository, and content hash of every package in a project’s library.
Package library. The directory into which R installs packages. A project-local library belongs to one project rather than being shared across all of a user’s work.
Snapshot. The act of scanning a project, resolving the versions currently installed, and writing them into the lockfile.
Restore. The reverse act of reading the lockfile and reinstalling exactly the packages it records.
DESCRIPTION. The human-authored file that declares a project’s direct dependencies by role (Imports, Suggests, Depends), with optional version floors.
Content hash. A short fingerprint computed from a package’s contents, so that two installations claiming the same version can be confirmed byte-identical.
Package repository. A server, such as CRAN or the Posit Package Manager, from which packages are installed.
Dated snapshot. A view of a repository frozen as it stood on a particular calendar day, so that an install run today and one run next year fetch the same versions.
6.4 From packrat to renv
The idea of a project-local library governed by a lockfile is not new to R. The packrat package, introduced in 2013, was the first widely adopted attempt to give each project its own isolated set of packages recorded in a manifest. Its mechanics were sound but its ergonomics were not, and it acquired a reputation for surprising behavior that discouraged routine use. The renv package, introduced by Ushey (2020) and now the community standard, superseded it in 2019. It kept the essential idea, a project-local library plus a lockfile. But it rebuilt the surface around a small, predictable set of verbs, a human-readable JSON lockfile, and a shared package cache that lets many projects share one physical copy of a given package version.
The design rests on a separation the analyst should hold clearly in mind. The library is where packages physically live; the lockfile is the written record of what should live there. The two are kept in agreement by two complementary operations. A snapshot reads the library and updates the lockfile to match it; a restore reads the lockfile and updates the library to match it. Everything renv does is a variation on keeping these two representations in step.
6.4.1 What the lockfile records
The lockfile is the artifact that does the pinning, so it repays close reading. For each package it records four things that together make a later reinstallation unambiguous: the exact version, the source repository the package came from, a content hash, and the package’s own declared dependencies. The top of the file additionally records the R version and the repositories in force. A lightly abbreviated excerpt, shown here as a static block because renv does not run while this book renders, conveys the structure.
Three features deserve comment. First, the version is recorded exactly, 1.2.1 and not >= 1.2, so there is no latitude for a later install to drift upward. Second, the source is named. A package that came from CRAN, from a Posit Package Manager mirror, from Bioconductor, or from a specific GitHub commit is restored from that same source. This matters because the same version number can denote different code on different repositories. The Repository value is the registered name of the repository the package was drawn from, matching one of the names in the Repositories block above. This is why the excerpt records CRAN even though that entry points at a Posit mirror. Third, the content hash lets renv confirm that a restored package is byte-identical to the one recorded, catching the rare but real case of a repository serving altered contents under an unchanged version string. The Requirements field lists each package’s own dependencies, so the lockfile captures the full transitive closure, not merely the packages the analyst named.
6.4.2 The init, snapshot, restore workflow
Three commands drive the ordinary lifecycle. Because none of them can execute while this book renders, we show them as static R blocks; the reader runs them at an interactive prompt inside the project.
Initialization creates the project-local library and a first lockfile.
renv::init()
From this point, packages installed into the project go into its private library rather than the shared system-wide one, so one project’s dplyr upgrade cannot disturb another’s. As the analysis develops and new packages are installed, a snapshot writes the current state into the lockfile.
renv::snapshot()
The snapshot does not blindly record every installed package. It first scans the project’s source for the packages the code actually references, through library(), require(), and pkg::fun() calls, and records those together with their transitive dependencies. A package sitting in the library but called nowhere in the code is, by default, left out, a behavior that keeps the lockfile a record of what the analysis uses rather than of everything that happens to be installed.
On another machine, or the same machine a year later, a restore reads the lockfile and rebuilds the library to match.
renv::restore()
This is the operation that redeems the pin. A collaborator who clones the repository and calls renv::restore() obtains the exact package set the analysis was written against, irrespective of what versions are current. Snapshot is capture; restore is the later consumption of what was captured.
In the framework this book uses, this lifecycle is partly automated so that an analyst does not forget the snapshot. The project’s generated .Rprofile installs an exit hook that snapshots the library when an interactive session ends, and restores missing packages when a session begins. A package installed during analysis is thus captured rather than lost. The automation does not change the model; it merely removes the two most common ways the library and the lockfile drift apart.
NoteCheck your understanding: snapshot versus restore
A colleague clones your project, opens R, and is puzzled that the packages are not yet installed. They ask whether they should run renv::snapshot() to get them. What do you advise, and why?
NoteAnswer
They should run renv::restore(), not renv::snapshot(). Restore reads the existing lockfile and installs the recorded versions into the local library, which is exactly what a fresh clone needs. Snapshot runs in the opposite direction: it would overwrite the lockfile with whatever is currently installed, which on a fresh clone is nothing relevant, and would thereby destroy the very record the colleague needs. The mnemonic is that snapshot writes the lockfile and restore reads it.
6.5 The dependency-triad invariant
A lockfile solves the pinning problem, but it introduces a bookkeeping problem in its place: the lockfile can fall out of step with the code it is supposed to describe. A contributor installs a package for an exploratory notebook and forgets to snapshot, so the code now uses a package the lockfile does not record. Or a package is dropped from the analysis but left in the project’s DESCRIPTION, so the declared dependencies name something the code no longer touches. Either drift silently degrades the pin.
The framework this book uses formalizes the correct relationship as the dependency triad. Three sources of information describe the packages an analysis uses, and in a healthy compendium they satisfy a nesting relation.
The code references some set of packages, through its library(), require(), and pkg::fun() calls.
The DESCRIPTION declares a set of direct dependencies.
The renv.lock records a set of pinned packages.
The invariant is the composition of two atomic inclusions, each set contained in the next:
Read left to right, it says that every package the code calls must be declared in DESCRIPTION, and every declared dependency must be pinned in the lockfile. The lockfile is the largest set because it additionally records the transitive dependencies that the analyst never named directly. Base and recommended packages, which every R installation provides, are excluded from the code set, so the first inclusion concerns only contributed packages.
One qualification keeps the invariant honest. Its strict form, with DESCRIPTION as the binding middle term, holds cleanly for the R-package archetype, where DESCRIPTION is the package’s declared dependency interface and every package the code calls belongs there by definition. For a research or analysis compendium the middle term is looser: DESCRIPTION declares only the API of the reusable code in R/, while packages used solely by analysis scripts or reports legitimately live in renv.lock without appearing in DESCRIPTION. The operative invariant for a compendium is therefore the weaker \(\mathrm{Code} \subseteq \mathrm{renv.lock}\), so the strict DESCRIPTION inclusion is exact for the package archetype but archetype-conditional in general. As a matter of placement an analysis-script dependency may still be listed in DESCRIPTION under Suggests. But the framework does not enforce the strict DESCRIPTION inclusion as a gate on a compendium, precisely because that inclusion does not hold for one. The gate it enforces on a compendium is the single inclusion \(\mathrm{Code}
\subseteq \mathrm{renv.lock}\), that nothing the code uses goes unpinned. The strict double inclusion is applied only in the package archetype, where DESCRIPTION is the authoritative dependency interface.
Figure 6.1 draws the nesting, with the archetype qualification marked rather than hidden. The regions are what matter: a package may sit legitimately in any of the three bands, and the only genuinely broken position is outside the lockfile altogether.
library(ggplot2)library(tibble)bands <-tibble(label =c('renv.lock: everything pinned','DESCRIPTION: declared','Code: actually called'),xmin =c(0.4, 1.5, 2.5), xmax =c(9.6, 8.5, 7.5),ymin =c(0.5, 1.3, 2.1), ymax =c(6.2, 5.4, 4.6),fill =c('#eef4f4', '#dcebea', '#c3ddda'))items <-tibble(x =c(5.0, 5.0, 2.6, 7.4),y =c(3.3, 1.75, 0.92, 0.92),lab =c('dplyr\n(called, declared, pinned)','an unused declaration','rlang, vctrs\n(transitive)','ggplot2, used only by\nanalysis scripts'))ggplot() +geom_rect(data = bands,aes(xmin = xmin, xmax = xmax,ymin = ymin, ymax = ymax, fill = fill),color ='grey40', linewidth =0.4) +scale_fill_identity() +geom_text(data = bands, aes(x = xmin +0.15, y = ymax -0.25,label = label),hjust =0, fontface ='bold', size =3.1) +geom_text(data = items, aes(x = x, y = y, label = lab),size =2.9, lineheight =0.95, color ='grey20') +annotate('point', x =3.55, y =0.02, shape =4,size =2.6, stroke =1.1, color ='#a33') +annotate('text', x =3.75, y =0.02, hjust =0,label ='survival: used but unpinned',size =3.1, fontface ='bold', color ='#a33') +coord_cartesian(xlim =c(0.2, 9.8), ylim =c(-0.35, 6.4)) +theme_void()
Figure 6.1: The dependency triad as nested sets, with example packages placed in each region. Everything the code calls must be pinned, so a package outside the lockfile is the one broken position and is marked with a cross. The band between DESCRIPTION and the lockfile holds the transitive dependencies no analyst names directly, together with, in a compendium, the packages used only by analysis scripts. That second case is the archetype qualification discussed above: it is a violation in an R package and legitimate in a compendium.
The invariant matters because it is checkable by machine. The framework’s check-renv gate, backed by the zzrenvcheck package, scans all three sources, tests the two inclusions, and reports any violation: a package used in code but undeclared, or declared but unpinned. Crucially it can repair many violations automatically. It writes a missing declaration into DESCRIPTION through the desc package, and for a package absent from the lockfile it queries the CRAN API for the current version and injects a resolved entry into renv.lock directly. It does not call renv::snapshot() or install.packages(); the repair edits the two manifests as text rather than installing anything or re-deriving the lockfile from the library. The analyst’s ordinary workflow is thus to write the analysis and let the check reconcile the bookkeeping.
$ make check-renv
This runs the check with automatic repair before a commit. The same check runs again under continuous integration, but there in a report-only mode: it runs strict and with repair disabled. A drift that escaped the developer’s machine therefore fails the build rather than being silently repaired. This forces the contributor to commit the fix, instead of letting the clean build paper over it. It is worth being precise about what the gate does and does not verify. It is a presence check: it guarantees that the three sets nest, that nothing used is unpinned. It does not verify that the version floors in DESCRIPTION agree with the exact pins in the lockfile, which is a separate concern.
6.5.1 Scanning the documentation, not only the code
A dependency check is only as good as its reach, and the most common way for one to fail is subtle. It scans the analysis code, pronounces the project consistent, and overlooks a package that is used only in a vignette or in the rendered manuscript. zzrenvcheck is deliberately more expansive than a plain code scan on this point, and the reason is worth understanding, because it is exactly where a documentation build quietly breaks on a fresh machine.
The scan does not look at .R scripts alone. It parses the literate and dynamic documents as well, R Markdown (.Rmd), Quarto (.qmd), and Sweave (.Rnw), so that a package loaded inside a report chunk or a vignette is seen just as one loaded in R/ is. Within every such file it recognizes each of the forms by which R code reaches for a package: the explicit library(pkg) and require(pkg), the namespaced pkg::function(), and the roxygen2 directives @import and @importFrom from which a package’s .Rd help files are generated. In its strict mode it widens the search further, into the tests/, vignettes/, and inst/ directories rather than the analysis directories alone. It is correspondingly careful about what it ignores: README files and the example directories (man/examples, inst/examples) are skipped, since the install-and-demonstrate code they usually carry would otherwise raise false alarms. A separate pass reads the reproducibility files themselves, the Dockerfile, the Makefile, install.sh, and .Rprofile, for version-pinned installs such as pak('survival@3.5-8'), catching a version fixed in the container recipe that has drifted from the lockfile.
The payoff is precisely the failure a code-only audit misses. Consider a manuscript compendium whose analysis in R/ restores cleanly from the lockfile, but whose report draws a forest plot with a plotting package that appears nowhere except the .qmd itself. A scanner that read only the code would certify the project as consistent, and the report would then fail to render on the reviewer’s machine for want of an unpinned package. Because zzrenvcheck reads the document, the missing dependency is caught and pinned before it can break the build. This reach matters most for the archetypes whose dependencies live in their documents rather than their code, the scholarly manuscript and the blog. Their rendered outputs are the point, and their package needs are therefore concentrated where a naive check does not look.
ImportantThe honest level
Passing check-renv confirms that the package layer is internally consistent, not that the analysis is reproducible in any fuller sense. The triad says the packages the code uses are all pinned; it says nothing about the R version, the operating system, or the system libraries beneath them. An analysis that passes the dependency check has reached L1 and no higher, and should be described as package-pinned, not as reproducible without qualification.
6.5.2 The triad across archetypes
The lockfile discipline is identical across the archetypes of Chapter 5, because every archetype records its packages in the same renv.lock by the same snapshot and restore. What varies slightly is how natural the declared-dependency middle term feels. In the R-package archetype, DESCRIPTION is not an optional extra but the package’s defining interface: R CMD check reads it, an installer reads it, and CRAN reads it. The triad is therefore especially natural there, since the declared set already exists for reasons independent of reproducibility. A data-analysis project, a manuscript compendium, a simulation study, and a blog also carry a DESCRIPTION, but there it declares only the API of the reusable code in R/. Their binding invariant is instead the single \(\mathrm{Code} \subseteq \mathrm{renv.lock}\). The R-package case is the one where the declared middle term would exist even if reproducibility were not a concern. A subtlety follows for the R-package archetype: a package used only by an analysis script or report belongs in Suggests rather than Imports, so that R CMD check does not hard-require a package the package’s own exported functions never call.
NoteCheck your understanding: locating a drift
Continuous integration on an R-package archetype fails with the message that survival is used in R/model.R but is not declared in DESCRIPTION. Which inclusion of the triad has been violated, and what single command would most likely repair it?
NoteAnswer
The invariant is the composition of two atomic inclusions, and the one violated here is the first, \(\mathrm{Code} \subseteq \mathrm{DESCRIPTION}\): the code references survival while DESCRIPTION does not declare it, which in turn leaves it unpinned in the lockfile. The transitive composition \(\mathrm{Code} \subseteq \mathrm{renv.lock}\) therefore fails as a consequence, but the atomic breach is at the code-to-DESCRIPTION step. The likely cause is that survival was installed interactively but never declared. Running make check-renv, which invokes zzrenvcheck::check_packages() with automatic repair, would write survival into DESCRIPTION through the desc package and inject a CRAN-resolved entry for it directly into renv.lock, restoring both inclusions. This edits the two manifests as text and is distinct from an renv::snapshot(), which would instead re-derive the lockfile from the installed library. The fix must then be committed so the clean CI build sees it.
6.6 Dated snapshots of a package repository
A lockfile records which versions to install. But a plain CRAN mirror will happily serve only whatever is current, and older source packages must often be recompiled, which is slow and can fail when a system library has since moved on. The Posit Package Manager resolves both problems by offering dated snapshots of CRAN. A snapshot URL freezes the repository as it stood on one calendar day, so that a request for a package returns the version that was current on that date, and returns it as a pre-compiled binary rather than as source to be built.
With repositories pointed at a dated snapshot, an install run today and the same install run next year draw from an identical, frozen catalog. The lockfile above records exactly this URL in its Repositories block, so that a restore reproduces not only the versions but the source they came from. The gain is twofold: fixity, because the catalog cannot drift, and speed, because binaries install in seconds where source builds take minutes. On Linux the binary path is more specific: pre-compiled binaries are served only from the distribution-codename form of the URL, such as https://packagemanager.posit.co/cran/__linux__/noble/2026-06-29, whereas the plain dated path shown above delivers source to be built. In the framework this book uses, the project’s generated configuration points R at a dated snapshot automatically, so that the two benefits accrue without the analyst assembling the URL by hand. The date of the snapshot becomes, in effect, part of the environment’s identity, and a topic we take up again when the container fixes a snapshot date at image-build time in Chapter 7.
6.7 The limits of renv
It is essential to be precise about what a lockfile does not do, because the temptation to overclaim is strong and the gap it leaves is exactly the subject of the next chapter. renv pins the R packages. It does not pin the R interpreter itself, so a lockfile produced under R 4.6.0 may restore under R 4.2.1 with different results, or fail to restore at all when a recorded version requires a newer R. It does not pin the operating system, so a lockfile assembled on macOS carries no guarantee on Linux. And it does not pin the system libraries that many packages link against, the GDAL behind a spatial package or the libxml2 behind an XML parser. A restore on a machine lacking a required system library may therefore fail to compile a package or, more insidiously, may install a differently behaving one.
The point is not a criticism of renv, which does its one job well. It is that the package layer is one layer of a taller stack, and pinning it leaves every layer beneath it as the next machine happens to provide. Ushey (2020) designed renv to solve the package problem and no more; the environment problem is out of its scope by design. An analysis equipped with a faithful lockfile has reached L1, pinned packages, and is exposed at L2 and below. Closing that remaining gap is the work of the container, and it is where Chapter 7 begins.
6.8 Lighter and alternative approaches
The lockfile-and-restore idiom is not the only way to pin the package layer, and a reader should know the principal alternatives, because some suit particular settings better and because the tradeoffs illuminate what renv is doing.
The most important shift of idiom is from preserving a lockfile to reconstructing an environment from a date. The rang package of Chan & Schoch (2023) takes a set of packages and a target date, and resolves the versions that were current then. It reaches back through CRAN’s archive to reconstruct a historical environment, even for a project that never recorded a lockfile at all. Where renv requires foresight, a committed lockfile written while the analysis was live, rang offers hindsight, recovering an approximate environment after the fact. It is the natural instrument for a legacy analysis that one wishes to resurrect.
The groundhog package of Simonsohn (2026) pursues the same date-based idea but at a different point in the workflow. Rather than restoring a library before the script runs, it loads version-specific packages at run time: a groundhog.library() call carries a date, and the packages current on that date are loaded for that session. It needs no project library and no snapshot step, trading renv’s explicit committed lockfile for a date-stamped call evaluated against the repository’s history. The convenience is real, and so is the cost: the pin now lives inside the script’s control flow rather than in a separate, inspectable manifest, which a reviewer must read the code to discover.
Finally, capsule(McBain, 2023) is best understood as a lazier discipline layered over renv itself. It defers building the project library until it is actually needed, letting the analyst develop against their ordinary user library and materialize the pinned library only when reproducing or checking the work. For analysts who find renv’s always-on project library intrusive during exploratory work, it lowers the friction of adoption while preserving the lockfile as the eventual source of truth.
Across all of these the invariant lesson holds. Each pins the package layer and only the package layer; whether the pin is a committed lockfile, a reconstructed date, or a run-time call, none reaches down to the R version, the operating system, or the system libraries. The choice among them is a choice of ergonomics and of foresight versus hindsight, not a choice of how far down the determinant stack one has pinned.
6.9 Worked example
To make the package-churn failure mode concrete, and to generate a computed artifact at build time, we construct a small synthetic lockfile diff: a comparison of two snapshots of the same project taken some months apart. This is the kind of table a version-control diff of renv.lock would summarize, and reading it is the routine skill of noticing which pins moved. The data are synthetic and no packages are installed; the computation uses only tibble, dplyr, and knitr.
Table 6.1: A synthetic lockfile diff between two snapshots of one project. The changed column flags every package whose pinned version moved between the earlier and later snapshot.
package
version_a
version_b
changed
dplyr
1.1.0
1.2.1
changed
ggplot2
3.4.4
3.4.4
tidyr
1.3.0
1.3.1
changed
survival
3.5-7
3.5-8
changed
lubridate
1.9.3
1.9.3
rlang
1.1.2
1.1.4
changed
Four of the six pins moved between the two snapshots. Three of the moves are patch-level (tidyr, survival, and rlang), the kind that ordinarily preserve behavior but occasionally do not. One is a minor version bump (dplyr, from 1.1.0 to 1.2.1) of the sort that can refine a default. The rlang move, from 1.1.2 to 1.1.4, is likewise patch-level. It is singled out here not for the distance it traveled but for its transitive position: a package almost nothing calls directly, yet almost everything depends on. It thus moved beneath the whole stack. The lesson is that a diff of the lockfile is a reviewable record of exactly this churn. Without a committed lockfile, none of these movements would be visible, and the analyst confronted with a changed result would have no way to see which package had shifted. The lockfile does not prevent packages from changing; it makes the change legible, and lets the analyst restore the earlier column deliberately rather than discover the drift by accident.
6.10 Collaborating with an LLM
A language model is a capable assistant for the mechanics of renv, but its confident fluency is precisely the hazard, because a plausible-looking lockfile instruction can pin the wrong thing or nothing at all. Each of the following triples pairs a useful prompt with what the analyst must watch for and verify.
Prompt. ‘My collaborator cloned our project and the packages are missing. Write the R command they should run.’
Watch for. A model may suggest reinstalling packages by hand with install.packages(), or may propose renv::snapshot(), either of which is wrong for a fresh clone. The correct answer is renv::restore().
Verification. Confirm that the suggested command reads the lockfile rather than writing it, and that after running it the installed versions match the lockfile. Run the check gate and confirm the triad holds.
Prompt. ‘Explain why my renv.lock includes roxygen2 and testthat when my analysis never calls them, and whether I should remove them.’
Watch for. A model may assert flatly that any unused package should be purged, missing the deliberate convention by which test infrastructure is pinned while pure development tooling is not. The distinction is a judgment, not a rule the model can read off the file.
Verification. Decide the placement yourself against the two-test procedure: does the package’s version affect the result, and does the code reference it? Confirm that development-only tools such as languageserver are in the Dockerfile and absent from the lockfile, and that test packages are handled by your stated convention.
Prompt. ‘Set my repository to a dated Posit Package Manager snapshot for 2026-06-29.’
Watch for. A model may produce a URL with the wrong path structure, an implausible date, or a mirror that does not offer binaries for your platform. It may also silently assume a Linux distribution codename that does not match your system.
Verification. Check the constructed URL against the Posit Package Manager documentation, confirm the date is the one you intend, and confirm that an install actually draws binaries rather than falling back to source. The recorded URL should then appear in the lockfile’s Repositories block.
6.11 Exercises
Initialize renv in a throwaway project, install two packages, and snapshot. Open renv.lock in a text editor and locate, for one package, its version, source, and hash. Explain in a sentence what each of the three would let a later restore verify.
With the project of Exercise 1, upgrade one package to a newer version, snapshot again, and inspect the diff of renv.lock under version control. Which fields changed, and which stayed fixed? Relate the diff to the synthetic table of Table 6.1.
Deliberately break the dependency triad: add a library() call for a package you have not declared or pinned. Run the project’s dependency check and record which inclusion it reports as violated and how it repairs the violation. Then remove the call and confirm the check passes.
Point your repository at a dated Posit Package Manager snapshot and reinstall one package. Time the install, then compare it with an install of the same package from source. Report the difference and explain, in terms of binaries versus source, why it arises.
Take a small analysis that has no lockfile and use rang to reconstruct the package environment as it stood on a chosen past date. Compare the resulting version set with what a current install.packages() would provide. Which packages differ, and by how much?
Argue, in a short paragraph, why an analysis that passes its dependency check has reached L1 and not L2. Name one concrete determinant that a lockfile leaves unpinned and describe a plausible way it could change a result across two machines.
6.12 Solutions
Attempt an exercise before expanding its solution. Two of the six have no single correct answer, because what they report depends on the machine and the date; for those the box says what a good answer establishes rather than supplying a number.
NoteExercise 1
The three fields answer three different questions a later restore has to settle.
The version is recorded exactly, 1.2.1 and not >= 1.2, so a restore installs the release the analysis actually used and has no latitude to drift upward. This is the field that defends against the ordinary failure, in which a package acquires a new default between the original run and the rerun.
The source names where the package came from: CRAN, a Posit Package Manager mirror, Bioconductor, or a specific GitHub commit. It matters because the same version number can denote different code on different repositories, so the version alone does not identify the package. Note that the Repository value is the registered name of the repository rather than its URL, which is why a lockfile can record CRAN while the corresponding entry in the Repositories block points at a Posit mirror.
The hash lets renv confirm that what it restored is byte-identical to what was recorded. It is the only one of the three that detects a repository serving altered contents under an unchanged version string. That case is rare, but it is the one the other two fields cannot see.
A useful way to hold the three together: the version says which release, the source says whose copy of it, and the hash says whether the bytes actually match.
NoteExercise 2
For the upgraded package, Version and Hash both change, and they change together. Package, Source, and Repository stay fixed, since the package is the same package drawn from the same place. The Requirements field may or may not change, depending on whether the new release altered its own dependencies.
Two further effects are worth noticing in the diff, and are easy to miss on a first reading. If the upgraded package gained a dependency, new package entries appear in the lockfile that you never installed by name, because the lockfile captures the full transitive closure rather than the packages you asked for. And the R block at the top of the file is untouched, which is the point of Table 6.1: the changed column flags packages, and the lockfile pins the R version separately from them.
The relation to Table 6.1 is that the table shows the same structure in synthetic form. Each row is a package, the earlier and later columns hold the pinned versions, and the flag marks where they differ. A real git diff renv.lock is that table expressed as JSON, with the additional detail that the hash moves whenever the version does.
NoteExercise 3
The violated inclusion is the first atomic one, \(\mathrm{Code} \subseteq \mathrm{DESCRIPTION}\). The code now references a package that DESCRIPTION does not declare, which in turn leaves it unpinned in the lockfile, so the transitive composition \(\mathrm{Code} \subseteq \mathrm{renv.lock}\) fails as a consequence. The breach to report is the atomic one, not the consequence.
Running make check-renv invokes zzrenvcheck::check_packages() with automatic repair. It writes the package into DESCRIPTION through the desc package and injects a CRAN-resolved entry for it directly into renv.lock, restoring both inclusions. This edits the two manifests as text and is deliberately distinct from an renv::snapshot(), which would instead re-derive the whole lockfile from whatever is currently installed.
One observation the exercise is designed to provoke, and which is worth stating because it surprises people. Removing the library() call and re-running the check does make the check pass, but it does not return the manifests to their prior state: the repair already wrote the package into DESCRIPTION and the lockfile, and nothing removes it. A passing check means the inclusions hold, not that the project is unchanged. Restoring the prior state requires deleting the entries by hand or discarding the changes under version control, which is itself a small argument for making the repair on a branch.
NoteExercise 4
This exercise has no single correct answer, because the times depend on the package, the machine, and the network. What a good answer establishes is the mechanism, and it should report both timings and name the package.
The expected result is that the snapshot install is substantially faster, and the explanation is that Posit Package Manager serves a prebuilt binary for the declared platform. Installing a binary is unpacking an archive; installing from source is compiling it. For a package carrying compiled code, typically anything depending on Rcpp, the difference is commonly an order of magnitude or more.
The instructive case is the one that does not show a large difference. A pure-R package has little to compile, so the two paths do similar work and the timings converge. A reader who happens to pick such a package and reports a small difference has the right finding, provided they identify why. Two other conditions can flatten the difference and are worth checking before concluding anything: the renv cache may already hold the package, in which case neither install does real work, and PPM does not serve binaries for every platform, so a machine whose platform is unserved falls back to source in both arms.
NoteExercise 5
This exercise also has no single correct answer, since what differs depends on the date chosen and on the size of the dependency tree. A good answer reports the count of differing packages, gives one or two concrete version pairs, and draws the general conclusion.
The general conclusion is that rang reconstructs what was current on a date, which is not the same thing as what an analysis actually used. The two coincide only if the original analyst installed everything on that date and never updated anything afterward, which is not how most projects proceed. Reconstruction by date is therefore an approximation, and a lockfile is a record; the approximation is what one falls back on when the record does not exist.
The number of differing packages will generally grow with the age of the date and with the depth of the dependency tree, since a difference anywhere in the transitive closure counts. It is worth noting that a package whose version happens to be unchanged is not evidence that the reconstruction succeeded, only that that particular package was quiet over the interval.
NoteExercise 6
A passing dependency check establishes that the triad holds: every package the code uses is declared, and every declared package is pinned in the lockfile. That is exactly the L1 property, pinned packages, and the check is the evidence for it.
It is not L2, because the lockfile pins packages and nothing beneath them. The R version is recorded in the lockfile but not supplied by it; the operating system, the system libraries, the locale, the time zone, and the numerical libraries are all outside its scope. L2 requires an artifact that carries those, which is what Chapter 7 is about.
For a concrete unpinned determinant, the numerical-library layer is the cleanest example. Two machines can hold identical pinned package sets and still link against different BLAS implementations, reference BLAS on one and Apple’s Accelerate or Intel MKL on the other. Because these reorder the floating-point summations inside a matrix decomposition, a principal-components analysis or a mixed-model fit can return results that agree to several decimal places and differ in the last, and a sign flip on a component can make the difference visible in a plot. The lockfile is silent on all of it, and setting a seed does not help, because the difference is not stochastic.
A second acceptable answer names the locale. Two machines under different collation orders sort character vectors differently, which changes which row is taken first in a tie and can therefore change a reported result through a slice() or a first(), with no error raised anywhere.
6.13 Further reading
The authoritative reference for the tool is Ushey (2020), the package documentation for renv, whose introductory and collaboration vignettes are the fastest route to fluent daily use and whose treatment of the cache and of custom repositories repays study once the basics are in hand. The relationship of renv to its predecessor packrat, and its place in the wider stack, is set out compactly in the survey portions of Chapter 1.
For the date-based alternatives, Chan & Schoch (2023) presents rang and its strategy of reconstructing a historical environment from CRAN’s archive, with a clear account of the cases where hindsight succeeds and where it cannot; Simonsohn (2026) documents groundhog and its run-time, date-stamped loading model; and McBain (2023) describes the lazy discipline that capsule layers over renv. Reading the three together clarifies that pinning the package layer admits several idioms with different demands on foresight.
On the discipline of where a package belongs, the placement whitepaper accompanying the framework develops the two-test decision procedure and the two-layer model that this chapter summarizes, and is the place to turn when a package sits awkwardly between the lockfile and the Dockerfile. Finally, the boundary this chapter insists on, that the lockfile pins the packages and nothing beneath them, is taken up directly in Chapter 7, where the container fixes the R version, the operating system, and the system libraries that renv by design does not.