Last modified: 2026-10-11 02:07:28 (PDT)
Just as wet labs have strict safety protocols to ensure reproducible results and prevent contamination, our computational lab has protocols for coding and data management. These protocols are not suggestions—they are essential practices that:
Violating these protocols can have serious consequences, including invalid results, wasted time, inability to publish, and damage to scientific credibility. Treat coding and data management protocols with the same seriousness as you would safety protocols in a wet lab.
A tool is anything that makes a task easier when that task would otherwise be repetitive, hard, or impossible — a script, a function, a package, a spreadsheet, a checklist, or a CI workflow. Aaron Crosman’s Tool Building Mindset argues for taking that expansive view deliberately: everyone who works with technology should, can, and already does build tools for themselves, whether or not they think of themselves as a “developer.”
The mindset in practice:
Much of our lab’s shared infrastructure came from exactly this pattern: the {snapr} package packages the lab’s snapshot-testing helpers, the {lms} linter package packages our shared lint configuration, and the Morrison-Lab/gha repository collects reusable CI workflows and composite actions shared across the lab’s repositories.
The following tools are essential for R package development in our lab:
usethis automates common package development tasks:
# Install usethis
install.packages("usethis")
# Create a new package
usethis::create_package("~/myproject")
# Add common components
usethis::use_mit_license() # Add a license
usethis::use_git() # Initialize git
usethis::use_github() # Connect to GitHub
usethis::use_testthat() # Set up testing infrastructure
usethis::use_vignette("intro") # Create a vignette (shipped with package)
usethis::use_article("case-study") # Create an article (website-only)
usethis::use_data_raw("dataset") # Create data processing script
usethis::use_package("dplyr") # Add a dependency
usethis::use_pipe() # Import magrittr pipe operator (no longer recommended)
# Increment version
usethis::use_version() # Increment package versiondevtools provides the core development workflow:
# Install devtools
install.packages("devtools")
# Load your package for interactive development
devtools::load_all() # Like library(), but for development
# Documentation
devtools::document() # Generate documentation from roxygen2
# Testing
devtools::test() # Run all tests
devtools::test_active_file() # Run tests in current file
# Checking
devtools::check() # R CMD check (comprehensive validation)
devtools::check_man() # Check documentation only
# Dependencies
devtools::install_dev_deps() # Install all development dependencies
# Building
devtools::build() # Build package bundle
devtools::install() # Install package locallyRun devtools::check() locally before pushing
Running R CMD check workflows on GitHub Actions introduces runner startup overhead and delay, as virtual environments must be provisioned and system-level dependencies configured on each run. Always run devtools::check() locally before pushing to GitHub to catch errors, warnings, and NOTEs early while conserving CI resources.
pkgdown builds beautiful documentation websites from your package:
# Install pkgdown
install.packages("pkgdown")
# Set up pkgdown
usethis::use_pkgdown()
# Build website locally
pkgdown::build_site()
# Preview in browser
pkgdown::build_site(preview = TRUE)
# Build components separately
pkgdown::build_reference() # Function reference
pkgdown::build_articles() # Vignettes
pkgdown::build_home() # Home page from READMEConfigure your pkgdown site with _pkgdown.yml:
url: https://ucd-serg.github.io/YOURPROJECT
template:
bootstrap: 5
reference:
- title: "Data Preparation"
desc: "Functions for preparing and cleaning data"
contents:
- prep_study_data
- validate_data
- title: "Analysis"
desc: "Core analysis functions"
contents:
- run_primary_analysis
- sensitivity_analysis
articles:
- title: "Analysis Workflow"
navbar: Analysis
contents:
- 01-data-preparation
- 02-primary-analysis
- 03-sensitivity-analysisWhile {pkgdown} is the standard tool for creating package documentation websites, several alternatives exist that may better suit specific needs, particularly for generating multiple output formats beyond HTML.
{altdoc} is a flexible alternative that supports multiple documentation frameworks, including Quarto, Docsify, MkDocs, and Docute. It is especially useful when you need to generate documentation in multiple formats.
Key features:
Basic usage:
# Install altdoc
install.packages("altdoc")
# Set up documentation with Quarto
altdoc::setup_docs(tool = "quarto_website")
# Or use other frameworks
# altdoc::setup_docs(tool = "docsify")
# altdoc::setup_docs(tool = "mkdocs")
# altdoc::setup_docs(tool = "docute")
# Render documentation
altdoc::render_docs()
# Preview in browser
altdoc::preview_docs()When to choose altdoc:
{pkgsite} provides a minimal, lightweight alternative to pkgdown, focusing on simplicity and ease of customization.
Key features:
build_site() function to pkgdownBasic usage:
# Install pkgsite (not on CRAN as of early 2026)
pak::pkg_install("pachadotdev/pkgsite")
# Build and preview site
pkgsite::build_site(preview = TRUE)
# Build with custom URL and lazy rebuilding
pkgsite::build_site(
# Replace with your real docs domain.
url = "https://example.com",
lazy = TRUE,
preview = TRUE
)When to choose pkgsite:
While not a dedicated package documentation tool, Quarto can be used to create sophisticated documentation websites for R packages, particularly when combined with custom scripts or tools like {ecodown} (experimental and intended for internal use).
Discussion and resources:
When to consider Quarto:
| Tool | Complexity | Website Output | Multi-format Support | Customization | Best For |
|---|---|---|---|---|---|
| pkgdown | Standard | HTML | Limited (via Quarto vignettes) | Template-based | Most R packages, standard documentation needs |
| altdoc | Moderate | HTML | Yes (via Quarto-authored vignettes with download links) | Framework-dependent | Quarto-based workflows, flexible frameworks |
| pkgsite | Minimal | HTML | No | High (simple CSS) | Lightweight sites, custom styling |
| Quarto | Advanced | HTML (for websites) | Yes (native - can output to PDF, DOCX, reveal.js, EPUB) | Complete | Full control, advanced features |
Recommendation: Use pkgdown for most standard R package documentation needs. Consider altdoc when you prefer Quarto’s publishing system or want flexibility to choose between documentation frameworks (Quarto, Docsify, MkDocs, Docute). If you choose altdoc with Quarto, you can author vignettes to provide downloadable PDF and DOCX versions alongside the HTML website. Choose pkgsite for minimalist websites with easy CSS customization. Use Quarto directly only if you need complete control and are comfortable with more complex setup.
Here’s the typical workflow for developing an R package in our lab:
Starting from a template (recommended):
Using our R package template is the fastest way to get started with a new R package, as it provides pre-configured settings, GitHub Actions workflows, and development tools:
rpt) - Our recommended template with pre-configured development tools and CI workflows:
The template includes pre-configured:
{usethis}, {devtools}, {roxygen2}){testthat})While the template jumpstarts your project with up-to-date configuration and workflow files, you should still come up to speed on what all the config files do so you can modify and debug them as needed. The template serves as a central location for the most current versions of these files and best practices.
Starting from scratch:
If you prefer to start from scratch or need to understand each setup step, you can create a new package manually:
Create functions in R/ directory with roxygen2 documentation:
#' Prepare Study Data
#'
#' Clean and prepare raw study data for analysis.
#'
#' @param raw_data A data frame containing raw study data
#' @param validate Logical; whether to run validation checks
#'
#' @returns A cleaned data frame ready for analysis
#'
#' @examples
#' raw_data <- read_csv("data.csv")
#' clean_data <- prep_study_data(raw_data)
#'
#' @export
prep_study_data <- function(raw_data, validate = TRUE) {
# Function implementation
}Create tests in tests/testthat/:
Run tests:
Fix any warnings or errors before proceeding.
Just as your data “flows” through your project, data should flow naturally through a script. Very generally, you want to:
0-config.R)Each of these sections should be “chunked together” using comments. See this file for a good example of how to cleanly organize a file in a way that follows this “flow” and functionally separate pieces of code that are doing different things.
ALWAYS establish tests BEFORE modifying functions. This ensures changes preserve existing behavior and new behavior is correctly validated.
When a PR adds a feature, give it a new test rather than editing an existing test’s inputs or expectations to also cover it. Repurposing a test this way folds two changes into one diff — the reviewer can no longer tell, at a glance, whether the edited assertions still protect the old behavior or now only protect the new one.
For example, suppose a run_model() test originally covered the "power" method and gets edited in place to cover "exponential" instead, rather than gaining a new test alongside it:
# Avoid --- repurposing an existing test to also cover a new method
test_that("run_model produces expected output", {
result <- run_model(data, method = "exponential") # was "power"
expect_snapshot_value(result)
})
# Preferred --- a new test for the new feature
test_that("run_model handles the exponential method", {
result <- run_model(data, method = "exponential")
expect_snapshot_value(result)
})If the new feature makes an existing test redundant, leave that redundancy alone for now. Remove or consolidate redundant tests in a separate, focused PR, so a reviewer can evaluate “does the new feature work” and “which old tests are now redundant” as two independent questions.
Use snapshot tests (expect_snapshot(), expect_snapshot_value()) when:
Our lab’s {snapr} package (on CRAN) adds convenience wrappers on top of {testthat}’s snapshot functions: expect_snapshot_data() for data frames (adapted, with permission, from the {ssdtools} package) and expect_snapshot_object(), which generalizes it to any R object.
Use explicit tests (expect_equal(), expect_identical()) when:
The two styles answer different questions. A snapshot test asks “does this output still match what it looked like before?”; an explicit test asks “does this specific property equal this exact value?”. That difference drives every practical trade-off, as Table 1 summarizes:
| Snapshot test | Explicit test | |
|---|---|---|
| Conciseness | High: one snapshot call pins the whole output, standing in for a long list of assertions, and the first run records the reference automatically | Lower: you write an assertion per property you care about, which adds up fast for complex outputs |
| Readability | Lower: the test shows that output is pinned, not why | Higher: each assertion documents an intended behavior |
| When output legitimately changes | One review-and-accept of the new snapshot | Rewrite the affected assertions by hand |
| Failure diagnosis | A diff of the whole output; the relevant change may be buried | Points at the exact property that broke |
Each style has a characteristic failure mode to guard against:
testthat::snapshot_accept() like a code review: read the diff (testthat::snapshot_review()) before accepting.In practice the strongest suites combine both: explicit assertions pin the core logic (the value a calculation must produce, the condition an input must trigger), and a snapshot guards the surrounding large structure (the full prepared data frame, a printed summary) against unnoticed drift. The {testthat} snapshotting vignette covers the workflow — recording, reviewing, and accepting snapshots — in more depth.
withr::local_seed() for reproducible testsWhen testing conditions with {testthat}, assert on the condition class whenever possible. Message text (regexp) can be checked as a secondary assertion, but should not be the only signal.
test_that("prep_study_data handles edge cases", {
# Empty input
expect_error(
prep_study_data(data.frame()),
class = "prep_study_data_empty_input_error"
)
# Missing required columns
expect_error(
prep_study_data(data.frame(x = 1)),
class = "prep_study_data_missing_columns_error",
regexp = "Missing required columns"
)
# Valid input with missing values
result <- prep_study_data(data.frame(id = 1:3, value = c(1, NA, 3)))
expect_true(all(!is.na(result$value)))
})Test-driven development (TDD) inverts the usual order of coding and testing: you write the test for a behavior before writing the code that implements it. The practice was popularized by Kent Beck, who developed it as part of extreme programming and described it in Test-Driven Development: By Example (2002).
Work proceeds in a short, repeated cycle (often called red-green-refactor):
Why it helps:
You do not have to practice strict TDD to benefit from it. Our testing guidance (Section 6) already applies its core move — establish tests before modifying existing functions — and writing the test first is just that same discipline applied to new code as well. In R packages, the mechanics are light: usethis::use_test() creates the matching {testthat} test file, and devtools::test() runs the suite (see R Packages, ch. “Testing basics”).
Note
This section draws from the Measuring Performance and Improving Performance chapters in Advanced R by Hadley Wickham, along with documentation for {bench} and {profvis}.
Performance optimization starts with measurement. Benchmarking helps identify bottlenecks, compare alternative implementations, and ensure your code meets performance requirements. This section covers when and how to benchmark R code, with a focus on integration with package development workflows.
Benchmark when:
Don’t benchmark prematurely. Write correct, readable code first, then measure to find what actually needs optimization.
Organize benchmarking code separately from unit tests:
mypackage/
tests/
testthat/ # Unit tests (run by R CMD check)
benchmarks/ # Benchmarking scripts (run manually or in CI)
01-data-prep.R
02-analysis.R
Add benchmarking dependencies to DESCRIPTION:
# Add bench and profvis as development dependencies
usethis::use_package("bench", type = "Suggests")
usethis::use_package("profvis", type = "Suggests")
# Add any packages used in benchmarks
# For example, if comparing with data.table or gbm:
# usethis::use_package("data.table", type = "Suggests")
# usethis::use_package("gbm", type = "Suggests")Create a benchmark template:
# tests/benchmarks/01-data-prep.R
library(bench)
library(mypackage)
# Generate test data
n <- 10000
test_data <- data.frame(
id = 1:n,
exposure = rnorm(n),
outcome = rbinom(n, 1, 0.3)
)
# Compare implementations
results <- bench::mark(
original = prep_data_v1(test_data),
optimized = prep_data_v2(test_data),
check = FALSE, # Set TRUE to verify outputs are identical
iterations = 100,
time_unit = "ms"
)
print(results){bench} provides accurate performance measurements and makes it easy to compare multiple implementations.
Basic usage:
library(bench)
# Compare different approaches
results <- bench::mark(
base_subset = data[data$group == "treatment", ],
dplyr_filter = dplyr::filter(data, group == "treatment"),
data.table = dt[group == "treatment"],
iterations = 100
)
# View results
print(results)
#> # A tibble: 3 x 6
#> expression min median `itr/sec` mem_alloc `gc/sec`
#> <bch:expr> <bch:tm> <bch:tm> <dbl> <bch:byt> <dbl>
#> 1 base_subset 1.2ms 1.4ms 714. 1.5MB 2.14
#> 2 dplyr_filter 2.1ms 2.3ms 435. 2.1MB 4.35
#> 3 data.table 850.0us 950.0us 1053. 800.0KB 0.00
# Plot results
plot(results)Key features:
Example for biostatistics workflow:
# Compare propensity score estimation methods
library(bench)
# Simulate cohort data
n <- 5000
cohort <- data.frame(
age = rnorm(n, 50, 15),
bmi = rnorm(n, 27, 5),
treatment = rbinom(n, 1, 0.5),
outcome = rbinom(n, 1, 0.3)
)
# Note: Install gbm first if not already installed
# usethis::use_package("gbm", type = "Suggests")
# Compare GLM vs GBM for propensity scores
ps_benchmark <- bench::mark(
glm_method = {
model <- glm(treatment ~ age + bmi,
data = cohort,
family = binomial())
predict(model, type = "response")
},
gbm_method = {
model <- gbm::gbm(treatment ~ age + bmi,
data = cohort,
distribution = "bernoulli",
n.trees = 100,
verbose = FALSE)
predict(model, n.trees = 100, type = "response")
},
check = FALSE,
iterations = 50
)
print(ps_benchmark){profvis} identifies where your code spends time and allocates memory. Use it to find bottlenecks in existing functions.
Basic usage:
The profvis output shows:
Example for data preparation:
library(profvis)
profvis({
# Profile data cleaning pipeline
cleaned <- raw_data |>
dplyr::filter(!is.na(id)) |>
dplyr::mutate(
age_group = cut(age, breaks = c(0, 18, 65, Inf)),
bmi_category = cut(bmi, breaks = c(0, 18.5, 25, 30, Inf))
) |>
dplyr::left_join(exposure_data, by = "id") |>
dplyr::group_by(age_group) |>
dplyr::summarize(
n = n(),
mean_bmi = mean(bmi, na.rm = TRUE)
)
})Interpreting profvis output:
Common bottlenecks in epidemiology code:
Integrate benchmarking into your development cycle:
1. Benchmark before optimization:
2. Profile to identify bottlenecks:
3. Optimize and re-benchmark:
4. Document performance characteristics:
#' Analyze cohort data
#'
#' @param data A data frame with cohort information
#' @return Analysis results
#'
#' @details
#' Performance characteristics (as of 2025-01):
#' - Typical runtime: ~50ms for 10,000 observations
#' - Memory usage: ~5MB per 10,000 observations
#' - Scales linearly with sample size
#'
#' @examples
#' \dontrun{
#' results <- analyze_cohort(cohort_data)
#' }
#' @export
analyze_cohort <- function(data) {
# Implementation
}Set up GitHub Actions to track performance over time.
Create .github/workflows/benchmark.yaml:
.github/workflows/benchmark.yaml
name: Benchmark
on:
pull_request:
branches: [main, master]
workflow_dispatch: # Manual trigger
jobs:
benchmark:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: r-lib/actions/setup-r@v2
with:
use-public-rspm: true
- uses: r-lib/actions/setup-r-dependencies@v2
with:
extra-packages: any::bench, any::profvis
- name: Run benchmarks
run: |
library(bench)
library(mypackage)
# Create benchmarks directory if it doesn't exist
dir.create("tests/benchmarks", recursive = TRUE, showWarnings = FALSE)
# Generate test data (or load existing test data)
test_data <- generate_test_cohort(n = 10000)
# Run benchmark
results <- bench::mark(
current_implementation = analyze_cohort(test_data),
iterations = 100
)
# Save results
saveRDS(results, "tests/benchmarks/current.rds")
shell: Rscript {0}
- name: Compare with baseline
run: |
# Load baseline and current results
baseline <- readRDS("tests/benchmarks/baseline.rds")
current <- readRDS("tests/benchmarks/current.rds")
# Calculate percentage change in median time
baseline_time <- as.numeric(baseline$median[1])
current_time <- as.numeric(current$median[1])
pct_change <- (current_time - baseline_time) / baseline_time * 100
# Report results
cat(sprintf("Performance change: %.1f%%\n", pct_change))
# Fail if performance degrades by >10%
if (pct_change > 10) {
stop(sprintf("Performance regression: %.1f%% slower", pct_change))
}
shell: Rscript {0}Key considerations for CI benchmarks:
workflow_dispatch for on-demand benchmarkingAlternative: Comment benchmark results on PRs:
Use the {touchstone} package for more sophisticated CI benchmarking with automated PR comments.
For critical performance requirements, add performance tests to your test suite.
Example performance test:
# tests/testthat/test-performance.R
test_that("data preparation meets performance requirements", {
skip_on_cran() # Skip on CRAN (timing-based tests can be flaky)
# Run on CI only if BENCHMARK_ON_CI is set
if (identical(Sys.getenv("CI"), "true") &&
!identical(Sys.getenv("BENCHMARK_ON_CI"), "true")) {
skip("Skipping performance test on CI")
}
n <- 10000
test_data <- generate_test_cohort(n)
# Measure execution time
timing <- bench::mark(
prep_study_data(test_data),
iterations = 10,
check = FALSE
)
# Require median time under threshold
max_time_ms <- 100
median_time_ms <- as.numeric(timing$median) * 1000
expect_true(
median_time_ms < max_time_ms,
info = sprintf(
"prep_study_data took %.1f ms (threshold: %.1f ms)",
median_time_ms,
max_time_ms
)
)
})
test_that("analysis scales linearly with sample size", {
skip_on_cran()
# Run on CI only if BENCHMARK_ON_CI is set
if (identical(Sys.getenv("CI"), "true") &&
!identical(Sys.getenv("BENCHMARK_ON_CI"), "true")) {
skip("Skipping performance test on CI")
}
# Test at different sample sizes
sizes <- c(1000, 5000, 10000)
timings <- vapply(sizes, function(n) {
data <- generate_test_cohort(n)
result <- bench::mark(
analyze_cohort(data),
iterations = 10
)
as.numeric(result$median)
}, numeric(1))
# Check approximate linearity (R^2 > 0.95)
model <- lm(timings ~ sizes)
r_squared <- summary(model)$r.squared
expect_true(
r_squared > 0.95,
info = sprintf("Scaling R^2 = %.3f (expected > 0.95)", r_squared)
)
})When to use performance tests:
When not to use performance tests:
See Section 6 for more on testing with {testthat}.
Focus benchmarks on realistic scenarios:
Establish baselines:
Before optimizing, measure current performance to understand the improvement.
Compare on equal footing:
# Good: Same random seed, same data
set.seed(123)
data <- generate_test_data(10000)
bench::mark(
method_a = analyze_a(data),
method_b = analyze_b(data),
check = FALSE
)
# Avoid: Different random data
bench::mark(
method_a = analyze_a(generate_test_data(10000)),
method_b = analyze_b(generate_test_data(10000))
)Benchmark critical paths only:
Don’t optimize everything—focus on code that:
Use appropriate sample sizes:
Document optimization decisions:
{bench} - Accurate benchmarking{profvis} - Interactive profiling{memoise} - Function-call caching to eliminate repeated bottlenecks (Section 37.2){touchstone} - CI benchmarking with PR commentsWhen applying analyses with different variations (outcomes, exposures, subgroups), use functional programming approaches:
lapply() and sapply()purrr::map() FamilyThe purrr package provides type-stable alternatives:
base::Vectorize() vs purrr::map_*()base::Vectorize() and {purrr} solve different problems.
base::Vectorize() when you already have a scalar function and want a quick base-R wrapper that accepts vector inputs.purrr::map_*() when you are iterating over lists or list-columns and want explicit, type-stable outputs (map_dbl(), map_int(), etc.) that compose naturally in tidyverse pipelines.base::Vectorize() is often convenient for small, local wrappers. In contrast, {purrr} is usually more idiomatic for analysis workflows because output types are explicit and iteration intent is clearer.
Also note that neither approach is parallel by default. For parallel execution, use furrr::future_map() from {furrr}.
purrr::pmap() for Multiple ArgumentsWhen iterating over multiple parameter lists:
params <- tibble(
outcome = c("outcome1", "outcome2", "outcome3"),
exposure = c("exp1", "exp2", "exp3"),
covariate_set = list(c("age", "sex"), c("age"), c("age", "sex", "bmi"))
)
results <- pmap(params, function(outcome, exposure, covariate_set) {
run_analysis(
data = study_data,
outcome = outcome,
exposure = exposure,
covariates = covariate_set
)
})For computationally intensive work, use future and furrr:
Use RDS format for R objects:
Avoid .RData files because: - You can’t control object names when loading - Can’t load individual objects - Creates confusion in older code
For tabular data that may be shared with non-R users:
To understand how Git tracks history, think of commits as Polaroid snapshots of your repository connected to their parent snapshots by strings. Branches and tags are both labels (like sticky notes) pointing to specific snapshots in this history graph:
v1.0.0 or a manuscript submission). Tags do not move when new commits are added; they remain anchored to their specific commit. Git provides two types of tags: lightweight tags (simple references directly pointing to a commit) and annotated tags (full Git objects storing a tagger identity, timestamp, and release message; recommended for official versions and public releases).For deeper background on Git’s object model and branching mechanisms, consult the official Git Branching in a Nutshell and Git Tagging guides in Pro Git by Scott Chacon and Ben Straub.
# Create and switch to a new feature branch
git switch -c feat/new-analysis
# Publish the feature branch to GitHub and set upstream tracking
git push -u origin feat/new-analysis
# View all local and remote-tracking branches
git branch -a
# Create a lightweight tag at the current commit
git tag v0.1.0
# Create an annotated tag with a release message (recommended for releases)
git tag -a v1.0.0 -m "Release version 1.0.0"
# Push a specific tag to GitHub
git push origin v1.0.0
# Push reachable annotated tags alongside branch commits (safer than pushing all tags)
git push --follow-tagsFollow semantic versioning (MAJOR.MINOR.PATCH):
0.0.0.9000, 0.0.0.9001, etc.0.1.00.1.0 → 0.1.1)0.1.1 → 0.2.0)0.2.0 → 1.0.0)Keep the development version ahead
Always keep the development version ahead of the main branch version. When working on a development branch, ensure the version in DESCRIPTION is higher than the version on main.
See R Packages - Version numbers for full guidelines.
Document all user-facing changes in NEWS.md.
Formatting conventions:
(#123) notation to link to related issues and pull requests (GitHub uses a shared number space for both)@username to credit external contributors only (not internal team members)See R Packages - NEWS.md for full conventions.
If your project uses README.Rmd (a README with executable R code), always edit README.Rmd, never README.md directly. README.md is auto-generated from README.Rmd.
To regenerate README.md after editing README.Rmd, use devtools::build_readme() (preferred for R packages):
Alternatively, you can use {rmarkdown}’s rmarkdown::render("README.Rmd") if {devtools} is not available.
Before requesting human review on a pull request or finalizing analysis, verify:
Use RStudio’s built-in autoformatter (keyboard shortcut: CMD-Shift-A or Ctrl-Shift-A) to quickly format highlighted code.
For automated styling of entire projects:
For checking code style without modifying files:
# Install lintr (and pkgload, used below)
install.packages(c("lintr", "pkgload"))
# For package code, load the package first so object_usage_linter()
# checks your current source (see the style guide for details)
pkgload::load_all()
# Lint the entire package
lintr::lint_package()
# Lint a specific file
lintr::lint("R/my_function.R")The linter checks for:
You can customize linting rules by creating a .lintr file in your project root.
{jarl} is a high-performance R linter written in Rust by Etienne Bacher. It checks many common style and syntax rules significantly faster than {lintr}, making it well-suited for fast pre-commit hooks and large codebases.
See also Automated Tools.
Every function you write must include documentation to describe its purpose, inputs, and outputs. For any reproducible workflows, this is essential, because R is dynamically typed. This means you can pass a string into an argument that is meant to be a data.table, or a list into an argument meant for a tibble. It is the responsibility of a function’s author to document what each argument is meant to do and its basic type.
We use {roxygen2} (Wickham et al. 2024) for function documentation. Roxygen2 allows you to describe your functions in special comments next to their definitions, and automatically generates R documentation files (.Rd files) and helps manage your package NAMESPACE. The roxygen2 format uses #' comments placed immediately before the function definition.
Here is an example of documenting a function using roxygen2:
#' Calculate flu season means by site
#'
#' Make a dataframe with rows for flu season and site
#' containing the number of patients with an outcome, the total patients,
#' and the percent of patients with the outcome.
#'
#' @param data A data frame with variables flu_season, site, studyID, and yname
#' @param yname A string for the outcome name
#' @param silent A boolean specifying whether to suppress console output
#' (default: TRUE)
#'
#' @returns A dataframe as described above
#'
#' @examples
#' calc_fluseas_mean(my_data, "hospitalized", silent = FALSE)
#'
calc_fluseas_mean <- function(data, yname, silent = TRUE) {
### function code here
}The roxygen2 header tells you what the function does, its various inputs, and how you might use it. Also notice that all optional arguments (i.e. ones with pre-specified defaults) follow arguments that require user input.
For more information on roxygen2 syntax and features, see https://roxygen2.r-lib.org/.
... (dots) and @inheritDotParamsThe ... argument (pronounced “dots” or “ellipsis”) is a special R construct that allows functions to accept additional arguments that are passed to other functions. This is particularly useful when creating wrapper functions that call other functions internally.
When to use ...:
Basic example with ...:
#' Plot data with custom ggplot2 styling
#'
#' A wrapper function that creates a scatter plot with custom theme settings.
#' Additional arguments are passed to ggplot2::geom_point().
#'
#' @param data A data frame containing the variables to plot
#' @param x A string specifying the x-axis variable name
#' @param y A string specifying the y-axis variable name
#' @param ... Additional arguments passed to ggplot2::geom_point()
#'
#' @returns A ggplot2 object
#'
#' @examples
#' # Pass color and size arguments to geom_point
#' plot_with_style(my_data, "age", "height", color = "blue", size = 3)
#'
plot_with_style <- function(data, x, y, ...) {
ggplot2::ggplot(data, ggplot2::aes(.data[[x]], .data[[y]])) +
ggplot2::geom_point(...) +
ggplot2::theme_minimal() # Apply a minimal theme
}While the example above documents ... with a simple description, roxygen2 provides @inheritDotParams to automatically inherit parameter documentation from the function you’re calling. This is more robust and maintainable because it automatically stays synchronized with the target function’s documentation.
Using @inheritDotParams:
#' Plot data with custom ggplot2 styling
#'
#' A wrapper function that creates a scatter plot with custom theme settings.
#'
#' @param data A data frame containing the variables to plot
#' @param x A string specifying the x-axis variable name
#' @param y A string specifying the y-axis variable name
#' @inheritDotParams ggplot2::geom_point -mapping -data -stat -position
#'
#' @returns A ggplot2 object
#'
#' @examples
#' # Pass color and size arguments to geom_point
#' plot_with_style(my_data, "age", "height", color = "blue", size = 3)
#'
plot_with_style <- function(data, x, y, ...) {
ggplot2::ggplot(data, ggplot2::aes(.data[[x]], .data[[y]])) +
ggplot2::geom_point(...) +
ggplot2::theme_minimal() # Apply a minimal theme
}The @inheritDotParams tag:
ggplot2::geom_point()-mapping -data -stat -position to exclude parameters that don’t make sense in this context... argumentsBest practices for ...:
Always document what receives the dots: Use @inheritDotParams when passing to a specific function, or clearly describe where the arguments go
Exclude irrelevant parameters: Use the -param_name syntax to exclude parameters that don’t apply
Validate unexpected arguments: Consider using the {ellipsis} package to catch misspelled argument names:
Consider alternatives: If you’re only passing a few specific arguments, it may be clearer to list them explicitly rather than using ...
For more details on @inheritDotParams, see the roxygen2 documentation on inheriting parameters.
@keywords internal over @noRdDocument every function you write, including internal helpers that only your other package functions call. The question is how the documentation is exposed, not whether it exists.
A helper is kept internal by omitting @export, which leaves it out of the export() directives in NAMESPACE (the function is still in the package namespace, which is why pkg:::fn works). Once the function is non-exported, two roxygen tags control how its documentation is generated and indexed, and they behave differently:
@noRd and @keywords internal roxygen tags
| Tag | Generates an .Rd file? |
Appears in pkgdown reference index? | Accessible via ?pkg:::fn? |
|---|---|---|---|
@noRd |
No | No | No |
@keywords internal |
Yes | No | Yes |
Prefer @keywords internal for any helper that isn’t part of the package’s public API.
For more on these tags, see the roxygen2 documentation and R Packages (2e): Function documentation. You still get a real .Rd file — so future maintainers (or future-you) can run ?pkg:::internal_helper and see the documentation — but the function stays out of the pkgdown reference and the package’s main help page.
@noRd is for cases where you genuinely don’t want documentation to exist at all (e.g., a one-line internal shim where the docstring would be longer than the function). Reach for it sparingly.
Example:
Note
As someone trying to call a function, it is possible to access a function’s documentation (and internal code) by CMD-Left-Clicking the function’s name in RStudio
Note
Depending on how important your function is, the complexity of your function code, and the complexity of different types of data in your project, you can also add “type-checking” to your function with the assertthat::assert_that() function. You can, for example, assert_that(is.data.frame(statistical_input)), which will ensure that collaborators or reviewers of your project attempting to use your function are using it in the way that it is intended by calling it with (at the minimum) the correct type of arguments. You can extend this to ensure that certain assumptions regarding the inputs are fulfilled as well (i.e. that time_column, location_column, value_column, and population_column all exist within the statistical_input tibble).
Every file in a project that doesn’t have roxygen function documentation should at least have a header that allows it to be interpreted on its own. It should include the name of the project and a short description for what this file (among the many in your project) does specifically. You may optionally wish to include the inputs and outputs of the script as well, though the next section makes this significantly less necessary.
################################################################################
# @Organization - Example Organization
# @Project - Example Project
# @Description - This file is responsible for [...]
################################################################################
Rstudio (v1.4 or more recent) supports the use of Sections and Subsections. You can easily navigate through longer scripts using the navigation pane in RStudio, as shown on the right below.
# Section -------
## Subsection -------
### Sub-subsection -------
Consider using RStudio’s code folding feature to collapse and expand different sections of your code. Any comment line with at least four trailing dashes (-), equal signs (=), or pound signs (#) automatically creates a code section. For example:
Commenting your code is an important part of reproducibility and helps document your code for the future. When things change or break, you’ll be thankful for comments. There’s no need to comment excessively or unnecessarily, but a comment describing what a large or complex chunk of code does is always helpful. See this file for an example of how to comment your code and notice that comments are always in the form of:
# This is a comment -- first letter is capitalized and spaced away from the pound sign
Add a descriptive label as soon as you create each key variable (any variable that might end up in a figure, table, or output model). The label should explicitly include measurement units where applicable.
In R, prefer the {labelled} package over raw base R attributes. {labelled} integrates seamlessly with tidyverse pipelines:
library(labelled)
# Set variable labels directly in a pipeline
data <- data |>
set_variable_labels(
blood_lead_level = "Blood lead concentration (ug/dL)",
pm25_exposure = "Annual average PM2.5 exposure (ug/m^3)"
)
# Set a single variable label using var_label()
var_label(data$blood_lead_level) <- "Blood lead concentration (ug/dL)"Labeling key variables at the point of creation ensures that downstream table generators (such as {gtsummary}) can automatically display human-readable descriptions and correct units without error-prone manual relabeling.
See also Function Structure and Documentation for function documentation style guidelines.
An Architecture Decision Record (ADR) is a short, versioned document that captures a significant design decision: the context that forced the choice, the alternatives considered, the decision itself, and its consequences. Function-level documentation and inline comments explain code; an ADR explains a decision that shapes many files at once and would otherwise live only in someone’s memory or a long-merged pull request thread.
Write an ADR for a non-obvious design choice that affects multiple files or systems: a concurrency or security trade-off, a data layout or dependency choice with long-term consequences, or a naming convention that needs justification. Routine choices that any reader would make the same way do not need one.
Use the four-part template from Michael Nygard’s “Documenting Architecture Decisions”:
Keep ADRs in the repository they describe, as docs/adr-<topic>.md files (or a docs/decisions/ directory once there are more than a few), so they are versioned, reviewable, and greppable alongside the code.
Point to the ADR with a terse inline comment — # see docs/adr-<topic>.md, "<section>" section — instead of duplicating the rationale in comments. This keeps scripts readable while making the full reasoning one hop away, and it means the rationale has one home to update when the decision changes.
A PR description explains why this change, now, and effectively disappears from view once the PR merges; an ADR persists in the repository and keeps answering “why is it built this way?” long after the merge. When a PR implements a significant decision, write the ADR in that PR and let the PR description summarize and link to it.
Generally we recommend using nouns for objects and verbs for functions. This is because functions are performing actions, while objects are not.
Try to make your variable names both more expressive and more explicit. Being a bit more verbose is useful and easy in the age of autocompletion! For example, instead of naming a variable vaxcov_1718, try naming it vaccination_coverage_2017_18. Similarly, flu_res could be named absentee_flu_residuals, making your code more readable and explicit.
We recommend you use snake_case.
. in variable names and functions (such as read.csv()), but this goes against best practices for variable naming in many other coding languages. For consistency’s sake, snake_case has been adopted across languages, and modern packages and functions typically use it (i.e. readr::read_csv()). As a very general rule of thumb, if a package you’re using doesn’t use snake_case, there may be an updated version or more modern package that does, bringing with it the variety of performance improvements and bug fixes inherent in more mature and modern software.Note
You may also see camelCase throughout the R code you come across. This is okay but not ideal – try to stay consistent across all your code with snake_case.
Note
Again, it’s also worth noting there’s nothing inherently wrong with using . in variable names, just that it goes against style best practices that are cropping up in data science, so it’s worth getting rid of these bad habits now.
See also Object Naming.
In a function call, use “named arguments” and put each argument on a separate line to make your code more readable.
Here’s an example of what not to do when calling the function a function calc_fluseas_mean (defined above):
mean_Y = calc_fluseas_mean(flu_data, "maari_yn", FALSE)
And here it is again using the best practices we’ve outlined:
mean_Y <- calc_fluseas_mean(
data = flu_data,
yname = "maari_yn",
silent = FALSE
)
Function decomposition improves code readability and maintainability by breaking complex logic into focused, reusable units. When a function becomes too long or handles too many concerns, split it into smaller helper functions with clear responsibilities.
Key goals of decomposition include:
Consider decomposition when one or more of these are true:
if-else; split the two cases into separate functions so each path has a clear name and contractA practical sequence is:
A common case for decomposition is a function that opens with a long sequence of input-validation checks. When the same validation pattern repeats for multiple inputs, extract it into a helper:
# Before: repeated validation blocks inline
build_analysis_cache <- function(
rebuild_signal, signal_files, signal_dir,
rebuild_finance, finance_files, finance_dir,
...) {
if (rebuild_signal && length(signal_files) == 0L) {
cli::cli_abort(c(
"Cannot rebuild Signal source data.",
"x" = "No raw files found in {.path {signal_dir}}.",
"i" = "Use {.val auto} to reuse an existing RDS."
))
}
if (rebuild_finance && length(finance_files) == 0L) {
cli::cli_abort(c(
"Cannot rebuild finance source data.",
"x" = "No raw files found in {.path {finance_dir}}.",
"i" = "Use {.val auto} to reuse an existing RDS."
))
}
# ... same pattern for mapping, then more for cached-RDS checks ...
}
# After: extract the repeated pattern into a helper
validate_source_rebuild <- function(
rebuild,
source_files,
label,
search_path,
call = rlang::caller_env()) {
if (!rebuild || length(source_files) > 0L) return(invisible(NULL))
cli::cli_abort(
c(
"Cannot rebuild {label} source data.",
"x" = "No raw {label} files were found in {.path {search_path}}.",
"i" = "Use {.val auto} to reuse an existing source RDS."
),
call = call
)
}
build_analysis_cache <- function(
rebuild_signal, signal_files, signal_dir,
rebuild_finance, finance_files, finance_dir,
rebuild_mapping, mapping_files, s_drive_path,
...) {
validate_source_rebuild(rebuild_signal, signal_files, "Signal", signal_dir)
validate_source_rebuild(rebuild_finance, finance_files, "finance", finance_dir)
validate_source_rebuild(rebuild_mapping, mapping_files, "mapping", s_drive_path)
# ... main logic is now immediately visible
}Benefits of this pattern:
if/cli_abort blocks.Place each extracted helper in its own file (Section 24), not above or below the caller in the same file.
In UCD-SERG/rpt PR #100, a top-level function was updated to call dedicated helpers, with helper logic split into separate files. The workflow moved from “do everything in one function” toward a clearer sequence of validation, cleaning, calculation, and formatting steps. This made the top-level function easier to read, while isolating validation and formatting details in reusable units.
In UCD-SERG/shigella PR #11, focused analysis functions were added in separate files, moving away from larger script-style helpers toward single-purpose, testable package functions.
For background and related ideas, see Decomposition (computer science), Code refactoring, and Functional decomposition.
Give each function its own file rather than bundling multiple functions into one file. This applies to exported functions, internal helpers, and test helpers alike — the only exception is a trivial two-line wrapper that is inseparable from its caller.
Under R/, name the file after the function it contains: simulate_data() lives in R/simulate_data.R. A private helper that is more than a two-liner still gets its own file, named after the helper, even if only one caller uses it.
A file with one function is easier to find, easier to diff (a change to one function does not sit inside an unrelated grep hit for a different one), and matches how git blame and code-review tools attribute changes. Bundling unrelated functions into one file — because they were added in the same PR, or because an existing file happened to be open — accumulates unrelated concerns over time.
When adding a function to a file that already holds others (common in older code that predates this convention), put the new function in its own file so at least new code follows the convention going forward. Splitting every pre-existing function out is a separate, larger refactor.
When decomposing a large function (1), place each extracted helper in its own file rather than above or below the caller in the same file. The caller file then contains only the orchestration function, and each helper is independently findable and testable.
Function length matters because very long functions can hide workflow intent, increase maintenance burden, and make targeted testing harder. The sources below combine community discussions (opinion-based) with a formal complexity reference. Use the community links for examples of practitioner viewpoints, and the complexity reference for a formal metric.
Sources:
Synthesizing these viewpoints for lab code yields the following practical rules:
<150 lines as a provisional heuristic trigger to reassess decomposition, not a hard constraint.The KISS principle — “Keep It Short and Simple” — says that most systems work best when they are kept simple, so unnecessary complexity should be avoided rather than managed. A blunter phrasing — the one aircraft engineer Kelly Johnson actually coined at the Lockheed Skunk Works — is “keep it simple, stupid”: his standing challenge was that his team’s aircraft must be repairable by an average mechanic in the field with only a handful of common tools.
The principle carries directly into software development: code is read, debugged, and modified far more often than it is written, and it is usually maintained by someone with less context than the original author had — often your own future self.
Rationales for keeping code short and simple:
Simplicity is not an excuse to drop requirements: the goal is the simplest solution that still does the whole job, not a simpler job. The preceding sections on function decomposition and function length limits, and the guidance on avoiding deep nesting, are concrete applications of this principle.
A Quarto document should read as narrative plus orchestration: each code chunk calls documented functions and displays their results, and the surrounding prose carries the analysis story. Complex logic belongs in named functions, not inline in chunks.
When a chunk grows into substantial logic — a multi-step data manipulation, a function defined inline, a non-trivial transformation, or the same block copied across chunks — extract that logic into a function and have the chunk call it. Following the function-decomposition (1), one-function-per-file (Section 24), and length-limit (Section 25) principles, give the function its own file under R/, with full {roxygen2} documentation, the same way any other package function is written. The chunk then shrinks to a call plus whatever narrative or display wraps it.
Why this matters: a function can be unit-tested, reused across chunks and analyses, and documented; inline chunk code can be none of these. Keeping logic inline also buries the analysis narrative under implementation detail, and duplicates code that should have one home.
Our shared linter set enforces the function-definition half of this rule: lms::function_location_linter() (part of lms::default_linters()) flags a named function defined at the top level of any file outside the designated directories (R/ and tests/ by default), including inside a .qmd chunk. Loose chunk logic that was never wrapped in a function at all is not something a linter can catch, so watch for it when writing and reviewing analyses: a chunk carrying substantial inline logic is a decomposition finding, and the fix is to move the logic into R/ and leave the chunk calling it.
The here package is one great R package that helps multiple collaborators deal with the mess that is working directories within an R project structure. Let’s say we have an R project at the path /home/oski/Some-R-Project. My collaborator might clone the repository and work with it at some other path, such as /home/bear/R-Code/Some-R-Project. Dealing with working directories and paths explicitly can be a very large pain, and as you might imagine, setting up a Config with paths requires those paths to flexibly work for all contributors to a project. This is where the here package comes in and this a great vignette describing it.
See also The here Package for code style guidelines on using the here package.
.RDS vs .RData FilesOne of the most common ways to load and save data in Base R is with the load() and save() functions to serialize multiple objects in a single .RData file. The biggest problems with this practice include an inability to control the names of things getting loaded in, the inherent confusion this creates in understanding older code, and the inability to load individual elements of a saved file. For this, we recommend using the RDS format to save R objects.
Note
If you have many related R objects you would have otherwise saved all together using the save function, the functional equivalent with RDS would be to create a (named) list containing each of these objects, and saving it.
Once again, the readr package as part of the Tidvyerse is great, with a much faster read_csv() than Base R’s read.csv(). For massive CSVs (> 5 GB), you’ll find data.table::fread() to be the fastest CSV reader in any data science language out there. For writing CSVs, readr::write_csv() and data.table::fwrite() outclass Base R’s write.csv() by a significant margin as well.
Box and Dropbox are cloud-based file sharing systems that are useful when dealing with large files. When our scripts generate large output files, the files can slow down the workflow if they are pushed to GitHub. This makes collaboration difficult when not everyone has a copy of the file, unless we decide to duplicate files and share them manually. The files might also take up a lot of local storage. Box and Dropbox help us avoid these issues by automatically storing the files, reading data, and writing data back to the cloud.
Box and Dropbox are separate platforms, but we can use either one to store and share files. To use them, we can install the packages that have been created to integrate Box and Dropbox into R. The set-up instructions are detailed below.
Make sure to authenticate before reading and writing from either Box or Dropbox. The authentication commands should go in the configuration file; it only needs to be done once. This will prompt you to give your login credentials for Box and Dropbox and will allow your application to access your shared folders.
Follow the instructions in this section to use the boxr package. Note that there are a few setup steps that need to be done on the box website before you can use the boxr package, explained here in the section “Creating an Interactive App.” This gets the authentication keys that must be put in box. Once that is done, add the authentication keys to your code in the configuration file, with box_auth(client_id = "<your_client_id>", client_secret = "<your_client_secret_id>"). It is also important to set the default working directory so that the code can reference the correct folder in box: box_setwd(<folder_id>). The folder ID is the sequence of digits at the end of the URL.
Further details can be found here.
Follow the instructions at this link to use the rdrop2 package. Similar to the boxr package, you must authenticate before reading and writing from Dropbox, which can be done by adding drop_auth() to the configuration file.
Saving the authentication token is not required, although it may be useful if you plan on using Dropbox frequently. To do so, save the token with the following commands. Tokens are valid until they are manually revoked.
# first time only
# save the output of drop_auth to an RDS file
token <- drop_auth()
# this token only has to be generated once, it is valid until revoked
saveRDS(token, "/path/to/tokenfile.RDS")
# all future usages
# to use a stored token, provide the rdstoken argument
drop_auth(rdstoken = "/path/to/tokenfile.RDS")
The r-lib GitHub organization, maintained primarily by the Posit team, hosts a collection of foundational R packages for package development, infrastructure, and general programming. These packages underpin much of the modern R ecosystem and are widely used in our lab’s workflows.
{usethis}{usethis} automates repetitive tasks that arise during project setup and development, both for R packages and non-package projects. It provides functions for setting up testing, CI, licenses, Git, and GitHub integrations, making it the go-to companion for devtools.
{devtools}{devtools} provides a suite of tools for the R package development workflow. It wraps many lower-level tools such as pkgbuild, pkgload, and rcmdcheck behind a convenient interface. Key functions include load_all(), document(), test(), check(), and install().
{roxygen2}{roxygen2} lets you write documentation in-source, alongside the code it documents, using #' comment blocks. It generates .Rd files for the man/ directory and updates NAMESPACE. It is the standard approach to R package documentation.
{pkgdown}{pkgdown} builds a documentation website for an R package from its source. It converts README, vignettes, and function documentation into a navigable website, and integrates with GitHub Actions for automatic deployment.
{pkgload}{pkgload} simulates the process of installing and loading a package, enabling rapid interactive development. It is the backend for devtools::load_all().
{pkgbuild}{pkgbuild} provides tools for building R packages, finding compilers, and checking whether a build environment (such as Rtools on Windows) is available.
{rcmdcheck}{rcmdcheck} runs R CMD check from within R and captures the results in a structured format. It is used by CI systems and devtools::check().
{remotes}{remotes} installs R packages from remote sources including GitHub, GitLab, Bitbucket, URLs, and local files. It is a lightweight alternative to devtools::install_github().
{pak}{pak} installs R packages from CRAN, GitHub, and other sources, with fast parallel downloads and automatic system-dependency handling. It is r-lib’s modern alternative to install.packages() and remotes for interactive use.
{revdepcheck}{revdepcheck} automates reverse dependency checking — checking that changes to a package do not break packages that depend on it. It is used by package maintainers before CRAN releases.
{desc}{desc} provides tools for reading, writing, and manipulating DESCRIPTION files. It is used internally by usethis and other developer-facing tools.
{pkgcache}{pkgcache} provides a local cache of CRAN-like metadata and package files, speeding up package installation. It is used internally by pak.
{lifecycle}{lifecycle} manages the life cycle of functions and arguments, providing the experimental/superseded/deprecated badges and deprecation warnings used across the tidyverse and r-lib ecosystems.
{testthat}{testthat} is the most widely used R testing framework. It provides a readable, expressive way to write unit tests for R packages, with functions like expect_equal(), expect_error(), and expect_snapshot().
{covr}{covr} measures and reports test coverage for R packages. It can generate HTML reports, submit results to Codecov, and is commonly integrated into CI pipelines.
{waldo}{waldo} finds and describes differences between R objects. It provides clearer, more informative output than identical() or all.equal(), and is used internally by testthat to display test failures.
{lintr}{lintr} performs static code analysis (linting) for R. It checks for style issues, potential errors, and common anti-patterns, and integrates with RStudio, VS Code, and CI pipelines.
{rlang}{rlang} provides a low-level API for working with R’s core language features, including environments, quosures, symbols, and non-standard evaluation (tidy eval). It is a foundational dependency for tidyverse packages and tidy eval programming.
{vctrs}{vctrs} defines a principled type system for R vectors, providing tools for coercion, casting, and size-stability. It underpins the consistent behavior of dplyr and tidyr across vector types.
{withr}{withr} allows code to be run with temporarily modified global state — changing options, environment variables, working directories, or random seeds — and reliably restoring them afterward. Essential for writing side-effect-free tests.
{cli}{cli} provides a rich toolkit for building command-line interfaces in R. It supports color, ANSI styling, progress bars, spinners, and formatted messages. It is the modern replacement for crayon and is used throughout the tidyverse.
{R6}{R6} implements encapsulated, reference-based object-oriented programming in R. Unlike S3 or S4, R6 classes support mutable state and inheritance in a style familiar from Python or Java.
{cpp11}{cpp11} provides a modern C++11 interface for interacting with R’s C API. It is a lightweight, header-only alternative to Rcpp, designed to simplify writing and debugging C++ extensions for R.
{fs}{fs} provides a cross-platform, uniform interface to file system operations. It replaces inconsistent base R file functions with a tidy API using path_*(), file_*(), dir_*(), and link_*() functions that always return character vectors.
{processx}{processx} runs external system processes asynchronously or synchronously from R, with full control over stdin, stdout, and stderr. It is the backend for callr, and is used internally by rcmdcheck.
{callr}{callr} calls R functions in separate R processes. This is useful for running code in a clean environment, isolating side effects, or parallelizing work. It builds on top of processx.
{ps}{ps} provides a cross-platform API for listing, querying, and manipulating system processes from R. It is used internally by processx and callr.
{httr2}{httr2} is a modern, pipe-friendly HTTP client for R. It replaces httr with a cleaner API, better support for OAuth, rate limiting, retries, and request/response bodies. Preferred for new code.
{httr}{httr} is a widely-used HTTP client for R, providing wrappers for GET(), POST(), PUT(), DELETE(), and other HTTP verbs, as well as OAuth support. It has been superseded by httr2 for new development.
{gh}{gh} provides a minimal client for the GitHub REST API. It handles authentication and pagination, and is used internally by usethis for GitHub operations.
{gargle}{gargle} provides authentication utilities for Google APIs, including OAuth 2.0, service accounts, and Application Default Credentials. It is used by googledrive, googlesheets4, and other Google-backed R packages.
{here}{here} builds file paths relative to the project root, so scripts work regardless of the current working directory. We recommend {here} for file paths (see Section 28).
{memoise}{memoise} adds memoisation (function-call caching) to R functions. Wrapping an expensive, pure function with memoise::memoise() causes it to cache computed return values in memory or on disk, so repeated calls with identical arguments return instantly from cache instead of recomputing. It supports multiple storage backends via the cache argument (such as cachem::cache_mem() for in-memory caching and cachem::cache_disk() for persistent disk caching across sessions), and provides memoise::forget() to clear caches, memoise::is.memoised() to test whether a function is memoised, and memoise::has_cache() to check whether specific arguments have cached results. Use {memoise} only for deterministic functions without side effects (such as pure mathematical evaluations, stable API queries, or idempotent data processing pipelines). Never memoise stochastic simulation routines or functions that generate pseudorandom numbers; memoisation suppresses stochastic variation across repeated calls, silently collapsing random variability by returning stale cached draws.
{scales}{scales} converts data values into perceptual properties and formats labels for dates, currencies, and percentages; it powers the scale system of {ggplot2}.
{sessioninfo}{sessioninfo} collects and prints detailed information about the current R session, including package versions and sources. It is a more informative replacement for base R’s sessionInfo().
{downlit}{downlit} provides syntax highlighting for R code and automatically links function names to their documentation. It is used by pkgdown and Quarto for rendering R code in documentation websites.
{prettyunits}{prettyunits} formats quantities such as file sizes, time durations, and byte counts in human-readable form. It is used internally by devtools, remotes, and progress-reporting packages.
{lobstr}{lobstr} provides tools for visualizing R data structures, including shared-reference trees (ref()), abstract syntax trees (ast()), and object sizes (obj_size()). It is a useful learning and debugging tool.
{crayon}{crayon} adds color and formatting to terminal output in R using ANSI codes. It has been largely superseded by cli but remains widely used in older code and packages.
{styler}{styler} formats R code according to the tidyverse style guide. It provides an RStudio add-in and functions for styling individual files, packages, or projects. See Section 13 for more details.
{rex}{rex} provides a human-readable interface for constructing regular expressions in R without having to write complex, unreadable regex strings manually. It allows regular expressions to be composed modularly using functions such as rex(), character_class(), capture(), and some_of().
When using {rex} inside another R package:
rex to Imports in the package DESCRIPTION.rex::register_shortcuts(pkgname) in .onLoad() to prevent R CMD check NOTEs regarding undefined global variables when using rex shortcuts in package code.rex::rex(...) to compile expressions into regular expression strings for use with functions like grepl(), gsub(), or {stringr} functions.The r-lib/actions repository provides reusable GitHub Actions workflows for R package development. Commonly used actions include:
r-lib/actions/setup-r — installs Rr-lib/actions/setup-renv — restores an renv environmentr-lib/actions/check-r-package — runs R CMD checkr-lib/actions/setup-r-dependencies — installs package dependenciesThese actions are widely used across R packages hosted on GitHub, including this lab’s repositories.
Throughout this document there have been references to the Tidyverse, but this section is to explicitly show you how to transform your Base R tendencies to Tidyverse (or Data.Table, Tidyverse’s performance-optimized competitor). For most of our work that does not utilize very large datasets, we recommend that you code in Tidyverse rather than Base R. Tidyverse is quickly becoming the gold standard in R data analysis and modern data science packages and code should use Tidyverse style and packages unless there’s a significant reason not to (i.e. big data pipelines that would benefit from Data.Table’s performance optimizations). Note that {dtplyr} provides a data.table backend for dplyr, enabling you to use most of dplyr’s tidy syntax with data.table’s performance optimizations.
The package author has published R for Data Science (Wickham et al. 2023), which leans heavily on many Tidyverse packages and may be worth checking out.
The tidyverse is a collection of R packages designed for data science that share an underlying design philosophy, grammar, and data structures. As of tidyverse 1.3.0, the following nine packages are included in the core tidyverse and are loaded automatically when you run library(tidyverse):
{ggplot2} is a system for declaratively creating graphics, based on The Grammar of Graphics. You provide the data, tell ggplot2 how to map variables to aesthetics and what graphical primitives to use, and it takes care of the details.
{dplyr} provides a grammar of data manipulation, providing a consistent set of verbs that solve the most common data manipulation challenges. Key functions include filter(), select(), mutate(), summarize(), and arrange().
{tidyr} provides a set of functions that help you get to tidy data. Tidy data is data with a consistent form: in brief, every variable goes in a column, and every column is a variable. Key functions include pivot_longer(), pivot_wider(), separate(), and unite().
While both {dplyr} and {tidyr} work with data frames, they serve different purposes:
Use dplyr for data manipulation within the current structure: filtering rows, selecting columns, creating new variables, summarizing data, or joining datasets. These operations work with your data as-is.
Use tidyr for reshaping your data structure itself: converting between wide and long formats (pivot_longer(), pivot_wider()), splitting or combining columns (separate(), unite()), or handling missing values explicitly (complete(), fill()).
These packages work together seamlessly in data analysis workflows. A typical pattern is to use tidyr to reshape your data into the right structure, then use dplyr to manipulate and analyze it.
{readr} provides a fast and friendly way to read rectangular data (like csv, tsv, and fwf). It is designed to flexibly parse many types of data found in the wild, while still cleanly failing when data unexpectedly changes.
{purrr} enhances R’s functional programming (FP) toolkit by providing a complete and consistent set of tools for working with functions and vectors. Once you master the basic concepts, purrr allows you to replace many for loops with code that is easier to write and more expressive. See Section 9 for more details on using purrr.
{tibble} is a modern re-imagining of the data frame, keeping what time has proven to be effective, and throwing out what it has not. Tibbles are data.frames that are lazy and surly: they do less and complain more forcing you to confront problems earlier, typically leading to cleaner, more expressive code.
{stringr} provides a cohesive set of functions designed to make working with strings as easy as possible. It is built on top of stringi, which uses the ICU C library to provide fast, correct implementations of common string manipulations.
{forcats} provides a suite of useful tools that solve common problems with factors. R uses factors to handle categorical variables, variables that have a fixed and known set of possible values.
{lubridate} provides a set of functions for working with date-times, extending and improving on R’s existing support for them. Key functions include ymd(), mdy(), dmy() for parsing dates, and year(), month(), day() for extracting components.
The following list is not exhaustive, but is a compact overview to begin to translate Base R into something better:
| Base R | Better Style, Performance, and Utility |
|---|---|
| _ | _ |
read.csv() |
readr::read_csv() or data.table::fread() |
write.csv() |
readr::write_csv() or data.table::fwrite() |
readRDS |
readr::read_rds() |
saveRDS() |
readr::write_rds() |
| _ | _ |
data.frame() |
tibble::tibble() or tibble::tribble() |
rbind() |
dplyr::bind_rows() |
cbind() |
dplyr::bind_cols() |
df$some_column |
df |> dplyr::pull(some_column) |
df$some_column = ... |
df |> dplyr::mutate(some_column = ...) |
df[get_rows_condition,] |
df |> dplyr::filter(get_rows_condition) |
df[,c(col1, col2)] |
df |> dplyr::select(col1, col2) |
merge(df1, df2, by = ..., all.x = ..., all.y = ...) |
df1 |> dplyr::left_join(df2, by = ...) or dplyr::full_join or dplyr::inner_join or dplyr::right_join |
| _ | _ |
str() |
dplyr::glimpse() |
grep(pattern, x) |
stringr::str_which(string, pattern) |
gsub(pattern, replacement, x) |
stringr::str_replace(string, pattern, replacement) |
ifelse(test_expression, yes, no) |
if_else(condition, true, false) |
Nested: ifelse(test_expression1, yes1, ifelse(test_expression2, yes2, ifelse(test_expression3, yes3, no))) |
case_when(test_expression1 ~ yes1, test_expression2 ~ yes2, test_expression3 ~ yes3, TRUE ~ no) |
proc.time() |
tictoc::tic() and tictoc::toc() |
stopifnot() |
assertthat::assert_that() or assertthat::see_if() or assertthat::validate_that() |
| _ | _ |
sessionInfo() |
sessioninfo::session_info() |
For a more extensive set of syntactical translations to Tidyverse, you can check out this document.
Working with Tidyverse within functions can be somewhat of a pain due to non-standard evaluation (NSE) semantics. If you’re an avid function writer, we’d recommend checking out the following resources:
See also Tidyverse Replacements
If you’re using both R and Python, you may wish to check out the Feather package for exchanging data between the two languages extremely quickly.
In many cases, we will need to apply our modeling on different combinations of interests (outcomes, exposures, etc.). We can certainly use a for loop to repeat the execution of a wrapper function, but generally, for loops request high memory usage and produce the results in long computation time.
Fortunately, R has some functions which implement looping in a compact form to help repeating your analyses with different variations (subgroups, outcomes, covariate sets, etc.) with better performances.
lapply() and sapply()lapply() is a function in the base R package that applies a function to each element of a list and returns a list. It’s typically faster than for. Here is a simple generic example:
result <- lapply(X = mylist, FUN = func)
There is another very similar function called sapply(). It also takes a list as its input, but if the output of the func is of the same length for each element in the input list, then sapply() will simplify the output to the simplest data structure possible, which will usually be a vector.
mapply() and pmap()Sometimes, we’d like to employ a wrapper function that takes arguments from multiple different lists/vectors. Then, we can consider using mapply() from the base R package or pmap() from the purrr package.
Please see the simple specific example below where the two input lists are of the same length and we are doing a pairwise calculation:
mylist1 = list(0:3)
mylist2 = list(6:9)
mylists = list(mylist1, mylist2)
square_sum <- function(x, y) {
x^2 + y^2
}
#Use `mapply()`
result1 <- mapply(FUN = square_sum, mylist1, mylist2)
#Use `pmap()`
library(purrr)
result2 <- pmap(.l = mylists, .f = square_sum)
#unlist(as.list(result1)) = result2 = [36 50 68 90]
There are two major differences between mapply() and pmap(). The first difference is that mapply() takes seperate lists as its input arguments, while pmap() takes a list of list. Secondly, the output of mapply() will be in the form of a matrix or an array, but pmap() produces a list directly.
However, when the input lists are of different lengths AND/OR the wrapper function doesn’t take arguments in pairs, mapply() and pmap() may not give the preferable results.
Both mapply() and pmap() will recycle shorter input lists to match the length of the longest input list. Assume that now mylist2 = list(6:12). Then, pmap(mylists, square_sum) will generate [36 50 68 90 100 122 148] where elements 0, 1, and 2 are recycled to match 10, 11, and 12. And it will return an error message that “longer object length is not a multiple of shorter object length.”
Thus, unless the recycling pattern described above is desirable feature for a certain experiment design, when the input lists are of different lengths, the best practice is probably to use lapply() and then combine the results.
Here is an example where we’d like to find the square_sum for every element combination of mylist1 and mylist2.
mylist1 <- list(0:3)
mylist2 <- list(6:12)
square_sum <- function(x, y) {
x^2 + y^2
}
results <- list()
for (i in seq_along(mylist1[[1]])) {
result <- lapply(X = mylist2, FUN = function(y) square_sum(mylist1[[1]][i], y))
results[[i]] <- result
}
This example doesn’t work in the way that 0 is paired to 6, 1 is paired to 7, and so on. Instead, every element in mylist1 will be paired with every element in mylist2. Thus, the “unlisted” results from the example will have \(4*7 = 28\) elements.
We can use flatten() or unlist() functions to decrease the depths of our results. If the results are data frames, then we will need to use bind_rows() to combine them.
parallel and future packagesOne big drawback of lapply() is its long computation time, especially when the list length is long. Fortunately, computers nowadays must have multiple cores which makes parallel processing possible to help make computation much faster.
Assume you have a list called mylist of length 1000, and lapply(X = mylist, FUN = func) applies the function to each of the 1000 elements one by one in \(T\) seconds. If we could execute the func in \(n\) processors simultaneously, then ideally, we would shrink the computation time to \(T/n\) seconds.
In practice, using functions under the parallel and the future packages, we can split mylist into smaller chunks and apply the function to each element of the several chunks in parallel in different cores to significantly reduce the run time.
parLapply()Below is a generic example of parLapply():
library(parallel)
# Set how many processors will be used to process the list and make cluster
n_cores <- 4
cl <- makeCluster(n_cores)
#Use parLapply() to apply func to each element in mylist
result <- parLapply(cl = cl, x = mylist, FUN = func)
#Stop the parallel processing
stopCluster(cl)
Let’s still assume mylist is of length 1000. The parLapply above splits mylist into 4 sub-lists each of length 250 and applies the function to the elements of each sub-list in parallel. To be more specific, first apply the function to element 1, 251, 501, 751; second apply to element 2, 252, 502, 752; so on and so forth. As such, the computation time will be greatly reduced.
You can use parallel::detectCores() to test how many cores your machine has and to help decide what to put for n_cores. It would be a good idea to leave at least one core free for the operating system to use.
We will always start parLapply() with makeCluster(). stopCluster() is not fully necessary but follows the best practices. If not stopped, the processing will continue in the back end and consuming the computation capacity for other software in your machine. But keep in mind that stopping the cluster is similar quitting R, meaning that you will need to re-load the packages needed when you need to do parallel processing use parLapply() again.
future.lapply()Below is a generic example of future.lapply():
library(future)
library(future.apply)
# First, plan how the future_lapply() will be resolved
future::plan(
multisession, workers = future::availableCores() - 1
)
# Use future_lapply() to apply func to each element in mylist
future_lapply(x = mylist, FUN = func)
Here, future::availableCores() checks how many cores your machine has. Similar to parLapply() showed above, future_lapply() parallelizes the computation of lapply() by executing the function func simultaneously on different sub-lists of mylist.
Peer code review is a collaborative practice that improves scientific rigor, fosters shared understanding, and catches bugs before analysis results are shared or published. The lab’s pull request workflow, author and reviewer expectations, and response protocol are consolidated in Collaborative Software Development.
Code review serves several vital purposes:
When reviewing a colleague’s code, evaluate these key dimensions:
{renv} (see Reproducible Environments)?{here} (see Section 28), avoiding machine-specific absolute paths?NA), edge cases, and empty groupings handled appropriately?Effective code review builds a supportive, high-trust research culture:
Approve a pull request when:
When you encounter a coding problem, creating a reprex (minimal reproducible example) is one of the most effective ways to get help—and often helps you solve the problem yourself.
A good reprex (Bryan et al. 2024):
library() calls and dataWhy create a reprex:
Resources:
{reprex} package: Automates creation of reproducible examples{gitdown} - R package for documenting git commit history as a bookdown report, organized by patterns like tags or issues