This document captures current best practices for R development, emphasizing modern tidyverse patterns, performance, and style. Last updated: August 2025
Core Principles #
- Use modern tidyverse patterns - Prioritize dplyr 1.1+ features, native pipe, and current APIs
- Profile before optimizing - Use profvis and bench to identify real bottlenecks
- Write readable code first - Optimize only when necessary and after profiling
- Follow tidyverse style guide - Consistent naming, spacing, and structure
Modern Tidyverse Patterns #
Pipe Usage (|> not %>%)
- Always use native pipe
|>instead of magrittr%>% - R 4.3+ provides all needed features
data |>
filter(year >= 2020) |>
summarise(mean_value = mean(value))
data %>%
filter(year >= 2020) %>%
summarise(mean_value = mean(value))
Join Syntax (dplyr 1.1+)
- Use
join_by()instead of character vectors for joins - Support for inequality, rolling, and overlap joins
transactions |>
inner_join(companies, by = join_by(company == id))
transactions |>
inner_join(companies, join_by(company == id, year >= since))
transactions |>
inner_join(companies, join_by(company == id, closest(year >= since)))
transactions |>
inner_join(companies, by = c("company" = "id"))
Multiple Match Handling
- Use
multipleandunmatchedarguments for quality control
inner_join(x, y, by = join_by(id), multiple = "error")
inner_join(x, y, by = join_by(id), multiple = "all")
inner_join(x, y, by = join_by(id), unmatched = "error")
Data Masking and Tidy Selection
- Understand the difference between data masking and tidy selection
- Use
{{}}(embrace) for function arguments - Use
.data[[]]for character vectors
my_summary <- function(data, group_var, summary_var) {
data |>
group_by({{ group_var }}) |>
summarise(mean_val = mean({{ summary_var }}))
}
for (var in names(mtcars)) {
mtcars |> count(.data[[var]]) |> print()
}
data |>
summarise(across({{ summary_vars }}, ~ mean(.x, na.rm = TRUE)))
Modern Grouping and Column Operations
- Use
.byfor per-operation grouping (dplyr 1.1+) - Use
pick()for column selection inside data-masking functions - Use
across()for applying functions to multiple columns - Use
reframe()for multi-row summaries
data |>
summarise(mean_value = mean(value), .by = category)
data |>
summarise(total = sum(revenue), .by = c(company, year))
data |>
summarise(
n_x_cols = ncol(pick(starts_with("x"))),
n_y_cols = ncol(pick(starts_with("y")))
)
data |>
summarise(across(where(is.numeric), mean, .names = "mean_{.col}"), .by = group)
data |>
reframe(quantiles = quantile(x, c(0.25, 0.5, 0.75)), .by = group)
data |>
group_by(category) |>
summarise(mean_value = mean(value)) |>
ungroup()
Modern rlang Patterns for Data-Masking #
Core Concepts
Data-masking allows R expressions to refer to data frame columns as if they were variables in the environment. rlang provides the metaprogramming framework that powers tidyverse data-masking.
Key rlang Tools
- Embracing
{{}}- Forward function arguments to data-masking functions - Injection
!!- Inject single expressions or values - Splicing
!!!- Inject multiple arguments from a list - Dynamic dots - Programmable
...with injection support - Pronouns
.data/.env- Explicit disambiguation between data and environment variables
Function Argument Patterns
Forwarding with {{}}
Use {{}} to forward function arguments to data-masking functions:
my_summarise <- function(data, var) {
data |> dplyr::summarise(mean = mean({{ var }}))
}
mtcars |> my_summarise(cyl)
mtcars |> my_summarise(cyl * am)
mtcars |> my_summarise(.data$cyl) # pronoun syntax supported
Forwarding ... (No Special Syntax Needed)
my_group_by <- function(.data, ...) {
.data |> dplyr::group_by(...)
}
my_select <- function(.data, ...) {
.data |> dplyr::select(...)
}
my_pivot_longer <- function(.data, ...) {
.data |> tidyr::pivot_longer(c(...))
}
Names Patterns with .data
Use .data pronoun for programmatic column access:
my_mean <- function(data, var) {
data |> dplyr::summarise(mean = mean(.data[[var]]))
}
mtcars |> my_mean("cyl") # No ambiguity, works like regular function
my_select_vars <- function(data, vars) {
data |> dplyr::select(all_of(vars))
}
mtcars |> my_select_vars(c("cyl", "am"))
Injection Operators
When to Use Each Operator
| Operator | Use Case | Example |
|---|---|---|
{{ }} |
Forward function arguments | summarise(mean = mean({{ var }})) |
!! |
Inject single expression/value | summarise(mean = mean(!!sym(var))) |
!!! |
Inject multiple arguments | group_by(!!!syms(vars)) |
.data[[]] |
Access columns by name | mean(.data[[var]]) |
Advanced Injection with !!
var <- "cyl"
mtcars |> dplyr::summarise(mean = mean(!!sym(var)))
df <- data.frame(x = 1:3)
x <- 100
df |> dplyr::mutate(scaled = x / !!x) # Uses both data and env x
mtcars |> dplyr::summarise(mean = mean(!!data_sym(var)))
Splicing with !!!
vars <- c("cyl", "am")
mtcars |> dplyr::group_by(!!!syms(vars))
mtcars |> dplyr::group_by(!!!data_syms(vars))
args <- list(na.rm = TRUE, trim = 0.1)
mtcars |> dplyr::summarise(mean = mean(cyl, !!!args))
Dynamic Dots Patterns
Using list2() for Dynamic Dots Support
my_function <- function(...) {
dots <- list2(...)
}
my_function(a = 1, b = 2) # Normal usage
my_function(!!!list(a = 1, b = 2)) # Splice a list
my_function("{name}" := value) # Name injection
my_function(a = 1, ) # Trailing commas OK
Name Injection with Glue Syntax
name <- "result"
list2("{name}" := 1) # Creates list(result = 1)
my_mean <- function(data, var) {
data |> dplyr::summarise("mean_{{ var }}" := mean({{ var }}))
}
mtcars |> my_mean(cyl) # Creates column "mean_cyl"
mtcars |> my_mean(cyl * am) # Creates column "mean_cyl * am"
my_mean <- function(data, var, name = englue("mean_{{ var }}")) {
data |> dplyr::summarise("{name}" := mean({{ var }}))
}
mtcars |> my_mean(cyl, name = "cylinder_mean")
Pronouns for Disambiguation
.data and .env Best Practices
cyl <- 1000 # Environment variable
mtcars |> dplyr::summarise(
data_cyl = mean(.data$cyl), # Data frame column
env_cyl = mean(.env$cyl), # Environment variable
ambiguous = mean(cyl) # Could be either (usually data wins)
)
vars <- c("cyl", "am")
for (var in vars) {
result <- mtcars |> dplyr::summarise(mean = mean(.data[[var]]))
print(result)
}
Programming Patterns
Bridge Patterns
Converting between data-masking and tidy selection behaviors:
my_group_by <- function(data, vars) {
data |> dplyr::group_by(across({{ vars }}))
}
mtcars |> my_group_by(starts_with("c"))
my_group_by <- function(data, vars) {
data |> dplyr::group_by(across(all_of(vars)))
}
mtcars |> my_group_by(c("cyl", "am"))
Transformation Patterns
my_mean <- function(data, var) {
data |> dplyr::summarise(mean = mean({{ var }}, na.rm = TRUE))
}
my_means <- function(data, ...) {
data |> dplyr::summarise(across(c(...), ~ mean(.x, na.rm = TRUE)))
}
my_means_manual <- function(.data, ...) {
vars <- enquos(..., .named = TRUE)
vars <- purrr::map(vars, ~ expr(mean(!!.x, na.rm = TRUE)))
.data |> dplyr::summarise(!!!vars)
}
Error-Prone Patterns to Avoid
Don't Use These Deprecated/Dangerous Patterns
var <- "cyl"
code <- paste("mean(", var, ")")
eval(parse(text = code)) # Dangerous!
!!sym(var) # Safe symbol injection
with(mtcars, mean(get(var))) # Collision-prone
with(mtcars, mean(!!sym(var))) # Safe
mtcars |> summarise(mean(.data[[var]])) # Even safer
Common Mistakes
my_func <- function(x) {
x <- force(x) # x is now a value, not an argument
quo(mean({{ x }})) # Wrong! Captures value, not expression
}
my_func <- function(data, var) data |> summarise(mean = mean({{ var }}))
my_func <- function(data, var) {
var <- enquo(var)
data |> summarise(mean = mean(!!var))
}
Package Development with rlang
Import Strategy
Imports: rlang
importFrom(rlang, enquo, enquos, expr, !!!, :=)
#' @importFrom rlang := enquo enquos
Documentation Tags
#' @param var <[`data-masked`][dplyr::dplyr_data_masking]> Column to summarize
#' @param ... <[`dynamic-dots`][rlang::dyn-dots]> Additional grouping variables
#' @param cols <[`tidy-select`][dplyr::dplyr_tidy_select]> Columns to select
Testing rlang Functions
test_that("function supports data masking", {
result <- my_function(mtcars, cyl)
expect_equal(names(result), "mean_cyl")
result2 <- my_function(mtcars, cyl * 2)
expect_true("mean_cyl * 2" %in% names(result2))
})
test_that("function supports injection", {
var <- "cyl"
result <- my_function(mtcars, !!sym(var))
expect_true(nrow(result) > 0)
})
This modern rlang approach enables clean, safe metaprogramming while maintaining the intuitive data-masking experience users expect from tidyverse functions.
Performance Best Practices #
Performance Tool Selection Guide #
When to Use Each Performance Tool
Profiling Tools Decision Matrix
| Tool | Use When | Don't Use When | What It Shows |
|---|---|---|---|
profvis |
Complex code, unknown bott |