renv + targets: the reproducible R workflow I actually use
Two tools, properly configured, eliminate almost every "it works on my machine" problem in R-based research. Here is the setup, the patterns, and the places where it breaks.
Micah Thornton, MS — Thornton Statistical Consulting
The problem with 'just use R'
R is an excellent language for statistical research. It has a rich ecosystem of packages, an active developer community, and decades of accumulated methodology in CRAN, Bioconductor, and GitHub repositories. It is also a language where reproducibility fails quietly and often. Package versions change. Function interfaces break. A script that ran cleanly on the analyst's laptop in January produces different numbers — or no numbers at all — when a colleague runs it in March on a different machine.
The problem is not incompetence. It is the default behavior of R's package management: packages are installed globally, they are updated silently when you run update.packages(), and nothing in the base language records which version of which package produced which result. A project with 30 package dependencies has at least 30 independent failure modes that can change without any action on the analyst's part.
There is a second, related problem: analysis scripts in research projects tend to grow into large, tangled files that run start-to-finish and cannot be partially re-executed cleanly. When a reviewer asks for a sensitivity analysis or a regulator asks for a re-run of a modified sub-analysis, the analyst must re-run the entire script, or carefully identify which portions are affected and manually re-execute them. This is error-prone and time-consuming — and it gets worse as projects grow.
renv solves the package version problem. targets solves the pipeline execution problem. Together they handle the two most common sources of computational irreproducibility in R-based research. This article describes how to set them up, how to configure them for research work specifically, and where they will not save you.
renv: what it does and what it doesn't
renv (Reproducible Environments) is Kevin Ushey's package for project-local R package libraries. The core idea is simple: instead of installing packages into a single global library shared across all projects, renv maintains a separate library for each project, with a lockfile (renv.lock) that records the exact version, source, and hash of every installed package. When another analyst (or a CI system, or you in six months) restores the project, renv::restore() reinstalls the exact package versions from the lockfile.
What renv does not do: it does not lock the R version itself. It does not manage system libraries. It does not ensure that compiled packages built on macOS will build identically on Linux. These limitations matter for long-term reproducibility — which is why renv is best combined with containerization (Docker or Apptainer) for truly archival work — but for the primary use case of making an analysis reproducible across the analyst's team and across time, renv covers the vast majority of failure modes.
The workflow is straightforward. Initialize renv at the start of a project:
# once, at project start renv::init() # after installing or updating any package renv::snapshot() # when restoring on a new machine or after pulling the lockfile renv::restore()
renv::init() creates the project library, adds renv/ to the project directory, and writes an initial renv.lock. It also modifies .Rprofile to activate the project library automatically when R starts in that directory — so the renv environment is transparent once initialized.
renv::snapshot() scans the project for package usage and updates the lockfile. It is not automatic. You must run it after installing, updating, or removing packages. Forgetting to snapshot is the most common renv failure in practice — the lockfile drifts from the actual library, and the next restore does not reproduce the analysis environment.
renv::snapshot() immediately after any package change, before committing. Add it to your pre-commit checklist alongside git status. A lockfile that is out of sync with the actual library is worse than no lockfile — it gives false confidence about reproducibility.renv has a global package cache that deduplications installations across projects. If you use tidyverse 2.0.0 in five projects, renv installs it once and creates project-local symlinks. This means the per-project library overhead is small — typically a few megabytes of metadata and symlinks, not a full copy of every package.
renv configuration for research workflows
The default renv setup works for most R projects. For research work — where you need CRAN-archived versions, Bioconductor packages, GitHub sources, and sometimes internal packages — there are several configuration decisions worth making explicitly.
Package sources: renv supports CRAN, Bioconductor, GitHub, GitLab, Bitbucket, and local source installs. The lockfile records the source for each package, so restores are source-aware. If you install a GitHub package during development and it disappears from GitHub before the lockfile is restored, the restore will fail. For long-term reproducibility, prefer CRAN-archived packages over GitHub sources where possible, and consider mirroring critical GitHub packages in an internal repository.
RSPM (RStudio Package Manager) versus CRAN: Posit's Public RSPM mirrors CRAN and provides pre-built binaries for Linux, which dramatically accelerates package installation in CI environments. Configuring renv to use RSPM reduces restore time from tens of minutes to a few minutes on a clean Linux machine:
# in .Rprofile or renv/settings.dcf
options(
repos = c(
RSPM = "https://packagemanager.posit.co/cran/latest",
CRAN = "https://cloud.r-project.org"
)
)Bioconductor: renv handles Bioconductor packages via renv::install("bioc::PackageName") and records the Bioconductor version in the lockfile. The Bioconductor release cycle (two releases per year) is slower than CRAN, so version drift is less acute — but still present. Specify the Bioconductor version in the lockfile explicitly rather than relying on the default for the installed R version.
For clinical trials work, a common pattern is a core set of packages (tidyverse, survival, nlme, lme4, emmeans) that are stable across projects plus a set of project-specific packages that may include sponsor-provided or regulatory-submission R packages. Keep the core packages as close to current CRAN as practical to benefit from bug fixes and security patches, and lock project-specific packages tightly.
targets: what it does and why it matters
targets is Will Landau's pipeline toolkit for R. It implements a directed acyclic graph (DAG) of analysis steps — called targets — where each step is a function that takes inputs and produces an output. targets tracks what has changed since the last run and skips steps whose inputs have not changed. When you modify a data cleaning function, targets re-runs that step and every downstream step that depends on it, but skips upstream steps and unrelated branches. It is Make for R analyses.
The consequence for research workflows is substantial. A primary analysis that takes two hours to run does not need to be re-run from scratch every time a figure label changes or a sensitivity analysis is added. targets identifies exactly which targets are outdated and runs only those. For a complex clinical trial analysis with dozens of models, multiple imputation, and a suite of secondary analyses, the difference between "full re-run" and "incremental update" can mean hours versus minutes.
The design philosophy of targets enforces a discipline that is independently valuable: every step must be a pure function. Inputs are explicit. Outputs are recorded. Side effects are discouraged. This makes the analysis auditable in a way that a monolithic script is not — you can inspect any intermediate result, understand exactly what produced it, and trace it to its inputs. For regulatory work where audit trails matter, this is not a nice-to-have. It is the point.
Setting up a targets pipeline
A targets pipeline lives in a file called _targets.R at the project root. It defines a list of targets using the tar_target() function, where each target specifies a name, a command, and optionally format and storage options. A minimal pipeline for a clinical trial analysis might look like this:
# _targets.R
library(targets)
library(tarchetypes)
# source all R functions
tar_source("R/")
# global options
options(tidyverse.quiet = TRUE)
# pipeline definition
list(
# data ingestion
tar_target(raw_data_file, "data/raw/trial_data.csv", format = "file"),
tar_target(raw_data, read_raw_data(raw_data_file)),
# data cleaning
tar_target(analysis_data, clean_and_derive(raw_data)),
tar_target(adsl, make_adsl(analysis_data)),
tar_target(adae, make_adae(analysis_data)),
# primary analysis
tar_target(primary_model, fit_primary_model(adsl)),
tar_target(primary_results, extract_primary_results(primary_model)),
# sensitivity analyses
tar_target(sensitivity_cc, fit_cc_sensitivity(adsl)),
tar_target(sensitivity_mi, fit_mi_sensitivity(adsl, n_imputations = 50)),
# outputs
tar_target(primary_table, render_primary_table(primary_results)),
tar_render(clinical_report, "reports/clinical_report.Rmd")
)Run the pipeline with tar_make(). Inspect the dependency graph with tar_visnetwork(). Check what is outdated with tar_outdated(). Load any target's result into your R session with tar_load(primary_results) or tar_read(primary_results).
The functions called in the pipeline — read_raw_data, clean_and_derive, fit_primary_model, and so on — live in the R/ directory. targets hashes these function bodies along with their inputs. If you modify a function, every target that calls it (directly or indirectly) becomes outdated and will be re-run on the next tar_make(). This is the mechanism that makes the pipeline self-auditing: changing anything that affects a result forces that result and all downstream results to be regenerated.
Function design for targets pipelines
The quality of a targets pipeline depends almost entirely on how well the functions are designed. Functions that are too coarse-grained — one function that cleans, transforms, and models all in one — defeat the purpose of incremental execution. Functions that are too fine-grained — one target per line of code — create dependency graph overhead without proportional benefit. The right granularity is roughly: one target per logical analysis step whose result you might want to inspect, re-use, or vary independently.
Several function design rules reduce pipeline fragility. First: functions should take data, not file paths, as inputs (except for the initial ingestion targets that handle file hashing). Passing file paths around the pipeline means targets cannot detect when the file content changes without an explicit format = "file" declaration. Passing data frames means targets hashes the data directly.
Second: avoid global state. Functions should not read from the global environment or modify it. options() calls inside functions, set.seed() at the top of a script rather than inside a function, and global variables referenced by name are all pipeline fragility sources — they mean the function's behavior depends on invisible context that targets cannot track.
Third: return structured outputs. Instead of returning a single model object from a fitting function, return a named list with the model, the data used for fitting, and the key results. This makes downstream targets more readable and makes it easier to extract exactly what you need without re-loading the entire model object.
# prefer this
fit_primary_model <- function(adsl) {
model <- lme4::lmer(
outcome ~ treatment + baseline + visit + (1 | subject),
data = adsl,
REML = FALSE
)
list(
model = model,
n = nobs(model),
formula = deparse(formula(model)),
data_hash = digest::digest(adsl)
)
}
# over this
fit_primary_model <- function(adsl) {
lme4::lmer(
outcome ~ treatment + baseline + visit + (1 | subject),
data = adsl
)
}Handling randomness: seeds in targets
Random number generation is one of the subtlest reproducibility problems in statistical pipelines. A target that calls a stochastic function — multiple imputation, bootstrap, MCMC sampling — will produce different results on each run unless the random seed is controlled. targets has a built-in seed mechanism: each target gets a deterministic seed derived from its name and the pipeline's global seed, which you set via tar_option_set(seed = your_seed).
# in _targets.R, before the pipeline list
tar_option_set(
seed = 20240115L, # document this in the SAP
packages = c("tidyverse", "lme4", "mice")
)With this configuration, each target's random seed is reproducible across runs — the same seed is used whenever that target is executed. This means multiple imputation, bootstrap confidence intervals, and simulation-based analyses produce bit-for-bit identical results on every run, on every machine that restores the same renv environment.
Document the seed in the SAP. Literally write "analyses were conducted using targets version X.Y.Z with global seed 20240115" in the statistical analysis plan. This is not formalism — it is the information an independent analyst would need to reproduce your results from the lockfile and pipeline definition.
Combining renv and targets: the complete setup
renv and targets are independent tools that compose cleanly. renv manages what packages are available; targets manages how the analysis uses them. The setup is additive: initialize renv first, then set up the targets pipeline, then snapshot the renv environment (which will now include targets, tarchetypes, and any other packages used in the pipeline).
The project structure for a research analysis using both tools:
project-root/ ├── _targets.R # pipeline definition ├── _targets/ # targets object store (gitignored) │ └── objects/ ├── renv/ # renv infrastructure │ ├── activate.R │ └── library/ # project-local package library ├── renv.lock # package version lockfile (git-tracked) ├── .Rprofile # activates renv (auto-generated) ├── R/ # analysis functions │ ├── data_cleaning.R │ ├── models.R │ └── outputs.R ├── data/ │ ├── raw/ # raw data (gitignored if sensitive) │ └── derived/ # derived datasets (gitignored) ├── reports/ │ └── clinical_report.Rmd └── output/ # figures, tables (gitignored)
What goes in git: _targets.R, everything in R/, renv.lock, .Rprofile, and the report templates. What does not go in git: _targets/ (the object store — large, machine-specific), renv/library/ (installed packages — large, machine-specific), raw data (often confidential), and generated outputs. The repository contains the recipe, not the ingredients or the finished dish.
The key insight is that git + renv.lock + _targets.R + R/ functions constitute a complete specification of the analysis. Anyone with R installed can renv::restore() to get the exact package environment and tar_make() to re-run the analysis from scratch and obtain identical results — assuming they have access to the same raw data.
| Problem | Solved by renv | Solved by targets |
|---|---|---|
| Package version drift | Yes — lockfile records exact versions | No |
| 'Works on my machine' | Mostly — for package-related failures | No |
| Full re-run on small changes | No | Yes — incremental execution skips unchanged targets |
| Audit trail for intermediate results | No | Yes — all targets stored and inspectable |
| Stochastic reproducibility | No | Yes — deterministic per-target seeds |
| Dependency tracking | No | Yes — DAG tracks what depends on what |
| R version differences | No — use Docker for R version locking | No |
| System library differences | No — use Docker | No |
Parallel execution with targets
targets supports parallel execution out of the box through the crew package. Independent branches of the DAG — targets whose inputs do not depend on each other — can be executed simultaneously on multiple local CPU cores or distributed across a computing cluster. For a clinical trial analysis with many sensitivity analyses, many subgroup analyses, or multiple imputation with many imputations, parallel execution can reduce wall-clock time by 4–8x on a standard workstation.
Enabling local parallel execution requires minimal configuration:
# in _targets.R library(crew) tar_option_set( controller = crew_controller_local(workers = 4), seed = 20240115L )
The crew package handles worker process management. targets sends targets to available workers, collects results, and updates the dependency graph as results arrive. No changes to individual target functions are required — the parallelism is at the pipeline level, not the function level.
For cluster execution (SLURM, PBS, AWS Batch), crew provides controller backends that launch jobs on the cluster infrastructure. This is relevant for computationally intensive analyses — large mixed models, MCMC, or high-dimensional genomic analyses — where local parallel execution is insufficient. The pipeline definition is identical; only the controller changes.
Dynamic branching for sensitivity analyses
One of the most powerful features of targets for clinical trial work is dynamic branching: the ability to define a family of related analyses — multiple sensitivity analyses, multiple subgroup analyses, multiple imputation models — as a single parameterized target that expands at runtime. Instead of writing one target per sensitivity analysis, you define the analysis function once and let targets run it over a list of parameter sets.
# define sensitivity analysis scenarios
tar_target(
sensitivity_params,
tibble::tribble(
~name, ~method, ~covariates,
"primary", "ANCOVA", c("baseline", "site"),
"adj_minimal", "ANCOVA", c("baseline"),
"adj_full", "ANCOVA", c("baseline", "site", "age", "sex"),
"unadjusted", "t-test", character(0)
)
),
# run each scenario as a separate target branch
tar_target(
sensitivity_results,
run_sensitivity(adsl, sensitivity_params),
pattern = map(sensitivity_params)
),
# aggregate results
tar_target(
sensitivity_table,
build_sensitivity_table(sensitivity_results)
)Each branch of sensitivity_results is a separate target with its own stored result and dependency tracking. If you add a new row to sensitivity_params, targets runs only the new branch on the next tar_make(). If you modify run_sensitivity, all branches are invalidated and re-run. This is exactly the behavior you want for a sensitivity analysis suite that evolves through the life of a trial.
Where this setup breaks — seven failure modes
The renv + targets combination is robust but not indestructible. Here are the failure modes that appear in practice.
- 1.renv.lock not snapshotted after package changes. The most common failure. You install a new package, run the analysis, commit the code — but forget to run renv::snapshot() before committing. The lockfile doesn't include the new package. Your colleague restores and gets a 'package not found' error. Fix: add renv::snapshot() to your pre-commit routine. Some teams use a pre-commit hook to check for lockfile staleness.
- 2.targets object store on a network drive. The _targets/ object store does many small file reads and writes. Network drives introduce latency that turns a 5-minute local run into a 45-minute run. Keep the object store on a local SSD. If you need to share targets results between team members, use tar_option_set(repository = 'aws') to store targets in S3 rather than on a shared network drive.
- 3.Functions with side effects invalidating targets unexpectedly. A function that writes to a log file, updates a database, or calls an API will hash differently if the log file path changes or the API response changes. targets sees the function body or the response as changed and invalidates the target. Isolate side effects to explicitly declared targets with format = 'file' for file outputs or keep them outside the pipeline for logging.
- 4.R version mismatch breaking compiled packages. renv locks package versions, not the R version. If one analyst uses R 4.3.2 and another uses R 4.4.0, packages with compiled C or Fortran code may behave differently in edge cases. For submission-quality work, document the R version explicitly and consider containerizing with Docker or Apptainer to lock the R version too.
- 5.Dynamic branching creating too many targets. A sensitivity analysis over a 100-row parameter grid creates 100 target branches. With 10 such analyses, the pipeline has 1000+ dynamic targets. The dependency graph computation becomes slow. The object store accumulates thousands of small files. For large parameter sweeps, consider batching: run groups of 10 scenarios per branch rather than one scenario per branch.
- 6.Forgetting to track data files as targets. Raw data files are inputs to the pipeline. If the data file changes — a corrected dataset is delivered, a query result changes — targets must know to invalidate the downstream targets. This requires declaring the data file as a target with format = 'file'. Passing the file path as a string constant to a data-reading function means targets will never detect data changes.
- 7.Pipeline definition scattered across multiple files. Some analysts split the _targets.R pipeline definition across multiple sourced files for organization. This works but makes the dependency graph harder to inspect and the pipeline harder to hand off. Keep the pipeline definition in a single _targets.R and put functions in R/ files. The pipeline definition is a document, not just code — it should be readable as a narrative of the analysis.
Integration with Quarto and R Markdown
targets integrates directly with Quarto and R Markdown through the tarchetypes package. The tar_render() and tar_quarto() functions declare a rendered document as a target, with automatic dependency tracking on both the source document and the targets it loads. If the primary results target changes, the rendered report is automatically re-rendered on the next tar_make(). If only the report text changes, targets re-renders without re-running any models.
Within the Quarto or R Markdown document, load targets using tar_load() or tar_read() rather than re-running analysis code inline. The document becomes a presentation layer that reads from the pre-computed pipeline store, not an analysis script embedded in a report template. This separation eliminates a major class of report-generation bugs where the report code diverges from the primary analysis code.
# in clinical_report.Rmd
---
title: "Primary Analysis Results"
params:
store: "_targets"
---
```{r setup, include=FALSE}
library(targets)
library(tidyverse)
tar_config_set(store = params$store)
```
```{r primary-table}
tar_load(primary_table)
primary_table |> knitr::kable()
```For regulatory submissions where the analysis outputs must be versioned and signed off separately from the report, this architecture is useful: the pipeline generates and stores the analysis objects; the report reads from those objects. The audit trail for any number in the report traces back through the report to the targets store to the function that generated it to the data it consumed.
A note on when not to use targets
targets adds overhead. For a simple analysis — 200 lines of R, one dataset, one model, a few figures — the overhead of designing a targets pipeline may exceed the benefit. You spend two hours structuring the pipeline; the analysis would have been done in 90 minutes as a script. For one-off exploratory analyses, for teaching examples, for quick ad-hoc summaries, a well-commented script with renv is sufficient.
The signal that targets is worth the investment: the analysis will be re-run. Not once, but many times — as data are updated, as reviewers request changes, as the protocol is amended, as colleagues extend the work. Any analysis that will be audited, submitted, or handed off to another team at some point is a candidate for targets. Any analysis that needs to run on a schedule (monthly safety summaries, interim monitoring reports) almost certainly benefits from targets.
Ten-point setup checklist
Before committing the first analysis code to a new project:
- 1.renv::init() has been run. The renv.lock file exists and is committed to git.
- 2.The R version is documented in a README or project manifest file, not just assumed.
- 3.The _targets.R pipeline definition exists and defines all major analysis steps.
- 4.tar_option_set() includes seed and packages. The seed is documented in the SAP.
- 5.All raw data files are declared as targets with format = 'file' so changes are detected.
- 6.Analysis functions live in R/ and do not rely on global state or side effects.
- 7.renv::snapshot() has been run after all packages were installed. The lockfile is not stale.
- 8.tar_visnetwork() has been run to inspect the DAG. The pipeline structure looks correct.
- 9.A .gitignore excludes _targets/ (object store) and renv/library/ (installed packages).
- 10.A collaborator (or CI) has run renv::restore() then tar_make() on a clean machine and confirmed the analysis reproduces.
The bottom line
The goal of renv + targets is not to be rigorous for its own sake. It is to build analysis workflows that are defensible — that can be handed to a colleague, a regulator, or your future self and reproduced without ambiguity. Most of the work in setting up this infrastructure happens once per project. The payoff accrues every time the analysis is re-run.
renv handles the question "which packages?" targets handles the question "in what order, and has anything changed?" Neither tool solves the question "is the analysis correct?" — that is still on you. But they make the first two questions transparent and automatic, which leaves more cognitive space for the question that actually matters.
The reproducibility problem in research is partly cultural and partly technical. The technical part — package version drift, undocumented dependencies, brittle serial scripts — is largely solved by these two tools. The cultural part — the discipline to structure analyses as functions rather than scripts, to snapshot after every change, to commit the lockfile alongside the code — requires habits, not software. This article is about the software. The habits are up to you.
Further reading
- Landau WM. The targets R package: a dynamic Make-like function-oriented pipeline toolkit for reproducibility and high-performance computing. Journal of Open Source Software. 2021;6(57):2959.
- Ushey K, Wickham H. renv: Project environments for R. Journal of Open Source Software. 2023;8(89):5817.
- Landau WM. targets documentation and manual. books.ropensci.org/targets. rOpenSci; 2024.
- R Submissions Working Group. Pilot 2 Submission: Using R in a Regulatory Submission. PhUSE/FDA; 2021. Available at: rconsortium.github.io/submissions-wg.
- Marwick B, Boettiger C, Mullen L. Packaging data analytical work reproducibly using R (and friends). The American Statistician. 2018;72(1):80–88.
- Sandve GK, Nekrutenko A, Taylor J, Hovig E. Ten simple rules for reproducible computational research. PLOS Computational Biology. 2013;9(10):e1003285.
- Wilson G, Bryan J, Cranston K, et al. Good enough practices in scientific computing. PLOS Computational Biology. 2017;13(6):e1005510.