Languages No. 116

R Tidyverse skill for Claude Code

Pipelines with predictable row counts and real plots. Claude reads data with explicit column types, predicts the row count of every dplyr step, and guards joins with relationship and unmatched checks so duplicates fail loudly. It writes tidy-eval functions with {{ }}, plots that state a finding with fixed export sizes, Quarto reports that render from a clean session, and keeps package versions pinned in renv.lock.

  • namer-tidyverse
  • size291 lines · ≈2,900 tokens
  • files2
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Writes and reviews R data analysis code in the tidyverse style. Covers dplyr verbs with .by grouping and join_by, tidyr pivots and nesting, readr column specs, purrr iteration, ggplot2 4.x charts built for a reader, tidy-eval wrappers with {{ }}, Quarto and R Markdown reports, and renv lockfiles for reproducible projects. Use when writing or debugging dplyr pipelines, reshaping data with pivot_longer or pivot_wider, building ggplot2 figures, turning a script into a Quarto report, writing functions that take column names, or pinning package versions with renv.

Try asking

  • My left_join doubled the number of rows — find out why and fix the pipeline
  • Turn this R script into a parameterized Quarto report that renders monthly
  • Write a dplyr helper function that takes a grouping column and a value column as arguments

Use it when

  • Writing or debugging dplyr, tidyr, or purrr pipelines
  • Building ggplot2 figures for a report or paper
  • Converting an R script into a Quarto or R Markdown document
  • Pinning package versions with renv for a shared project

Skip it when

How to check the result

Restart R and run the script or quarto render end to end after renv::restore(); key-uniqueness and row-count assertions after each join pass.

The common mistake it prevents

Leaving a data frame grouped after group_by(), so later mutate or summarise calls silently compute per group instead of over the whole table.

~/.claude/skills/r-tidyverse/SKILL.md

--- name: r-tidyverse description: Writes and reviews R data analysis code in the tidyverse style. Covers dplyr verbs with .by grouping and join_by, tidyr pivots and nesting,… license: MIT ---

Done well means: data enters through an explicit column spec, every transformation is a readable pipeline whose row count you can predict, joins are checked for duplication, plots answer one question each, and a fresh clone reproduces the output with renv::restore() plus one render command.

Ground rules

  • Use the native pipe |> (R ≥ 4.1). Keep %>% only in codebases that already use it or when you need the . placeholder in a non-first position (native uses _ with a named argument, R ≥ 4.2: x |> lm(y ~ z, data = _)).
  • Never call library(tidyverse) inside package code; import specific functions (dplyr::filter) and declare packages in DESCRIPTION. In scripts, library() calls go at the top, never require() mid-script.
  • Never setwd() in a script. Use project-relative paths with here::here("data", "x.csv") or a Quarto/RStudio project root.
  • filter() drops NA rows silently. State missing-value handling deliberately.
  • Prefer one data frame per entity, one row per observation (tidy data). Reshape at the edges: wide for humans and some models, long for dplyr and ggplot2.

Workflow for an analysis task

- [ ] 1. Inspect before transforming: glimpse(), count() on keys, summary(), n_distinct()
- [ ] 2. Read with explicit col_types; fix parsing problems (readr::problems()) at read time
- [ ] 3. Assert keys: the column(s) you believe are unique really are
- [ ] 4. Transform in small named steps; print nrow() after each join or filter while developing
- [ ] 5. Plot or model; save outputs with fixed sizes and seeds
- [ ] 6. Move into a Quarto doc or targets pipeline if it will be rerun
- [ ] 7. renv::snapshot(); render from a clean session to prove reproducibility

Reading data

library(readr)
orders <- read_csv(
  here::here("data", "orders.csv"),
  col_types = cols(
    order_id   = col_character(),          # IDs are strings, never numbers
    placed_at  = col_datetime(format = "%Y-%m-%d %H:%M:%S"),
    amount     = col_double(),
    status     = col_factor(levels = c("pending", "paid", "refunded")),
    .default   = col_guess()
  ),
  na = c("", "NA", "NULL")
)
stopifnot(nrow(problems(orders)) == 0)
SourceUse
CSV/TSVreadr::read_csv / read_tsv (or vroom for many files at once)
Excelreadxl::read_excel(sheet =, range =)
Parquet / larger-than-memoryarrow::open_dataset() then dplyr verbs, collect() at the end
SQL databaseDBI::dbConnect() + dplyr::tbl(); inspect with show_query() before collect()
SPSS/Stata/SAShaven::read_*, then haven::as_factor() on labelled columns

dplyr: verbs that predict their row count

VerbRows out
filter(), slice_*(), distinct()≤ rows in
mutate(), select(), arrange(), rename()= rows in
summarise()one per group (use reframe() when a summary returns several rows)
left_join()≥ rows in; > means duplicate keys on the right
count()one per combination
library(dplyr)

monthly <- orders |>
  filter(status != "refunded") |>
  mutate(month = lubridate::floor_date(placed_at, "month")) |>
  summarise(
    revenue = sum(amount, na.rm = TRUE),
    n_orders = n(),
    .by = c(month, region)                 # per-operation grouping, no ungroup() needed
  ) |>
  arrange(month, desc(revenue))
  • Prefer .by (dplyr ≥ 1.1) over group_by() |> ... |> ungroup(). Leftover grouping is the most common silent bug in dplyr code; if you do use group_by(), end with ungroup() or summarise(.groups = "drop").
  • case_when() for multi-branch logic; the fallback is .default = not TRUE ~.
  • if_else() over base ifelse() (type-stable, keeps dates and factors intact).
  • across() for applying a function to many columns: mutate(across(where(is.numeric), \(x) round(x, 2))).
  • dplyr ≥ 1.2.0 adds filter_out() (drop matching rows, treating NA as not matching) and when_any()/when_all(); check packageVersion("dplyr") before using them.

Joins without silent duplication

customers |>
  left_join(
    orders,
    by = join_by(customer_id),
    relationship = "one-to-many",          # errors if the shape differs
    unmatched = "error"                    # left join: errors if any order has no customer
  )

# Rolling / inequality joins
prices |> left_join(rates, by = join_by(currency, closest(date >= rate_date)))
  • Before any join, check key uniqueness: orders |> count(order_id) |> filter(n > 1).
  • Use relationship = "many-to-one" when enriching facts with a lookup table; dplyr then errors instead of multiplying rows.
  • anti_join() is the fastest way to list records that failed to match.

tidyr: reshaping

library(tidyr)
long <- wide |>
  pivot_longer(
    cols = starts_with("q"),
    names_to = c("quarter", "metric"),
    names_sep = "_",
    values_to = "value"
  )

wide_again <- long |>
  pivot_wider(names_from = metric, values_from = value, values_fill = 0)
  • pivot_wider producing list-columns with a warning means duplicate id rows; fix the ids (or pass values_fn) rather than ignoring the warning.
  • complete() makes implicit missing combinations explicit (zero-sales months); fill() carries values down; separate_wider_delim() replaces separate().
  • nest() + purrr::map() + unnest() for per-group models; for simple per-group summaries summarise(.by =) is faster and clearer.

purrr and functional iteration

  • map() family over for loops that grow vectors. Use typed variants: map_dbl, map_chr, map_lgl; list_rbind() replaces map_dfr().
  • Anonymous functions: \(x) x + 1 (R ≥ 4.1) instead of ~ .x + 1 in new code.
  • Wrap fallible calls with possibly() or safely() when processing many files, then report the failures instead of dropping them.
files <- fs::dir_ls(here::here("data", "raw"), glob = "*.csv")
all_data <- files |>
  purrr::map(\(f) read_csv(f, col_types = spec) |> mutate(source = fs::path_file(f))) |>
  purrr::list_rbind()

Writing functions that take column names (tidy eval)

summarise_by <- function(data, group_col, value_col) {
  data |>
    summarise(mean = mean({{ value_col }}, na.rm = TRUE), .by = {{ group_col }})
}
summarise_by(orders, region, amount)

# Column names as strings (from config or Shiny inputs)
col <- "amount"
orders |> summarise(total = sum(.data[[col]]))

# Dynamic output names
make_total <- function(data, var) {
  data |> summarise("total_{{ var }}" := sum({{ var }}))
}
  • {{ }} for bare column arguments, .data[[name]] for strings, all_of(vec) inside select()/across() for character vectors. Never get() or eval(parse()).
  • In packages, refer to columns as .data$col and .env$var to avoid R CMD check “no visible binding” notes.

ggplot2 for readers

library(ggplot2)
p <- monthly |>
  ggplot(aes(x = month, y = revenue, colour = region)) +
  geom_line(linewidth = 0.8) +
  scale_y_continuous(labels = scales::label_currency()) +
  scale_colour_brewer(palette = "Dark2") +
  labs(
    title = "Revenue grew in every region after the March price change",
    x = NULL, y = "Monthly revenue", colour = "Region",
    caption = "Source: orders table, refunds excluded"
  ) +
  theme_minimal(base_size = 12) +
  theme(legend.position = "bottom")

ggsave(here::here("out", "revenue.png"), p, width = 7, height = 4.5, dpi = 300, bg = "white")
  • Title states the finding, not the variables. Axis labels carry units.
  • linewidth (not size) for lines and borders since 3.4.
  • Put mappings in aes(), constants outside it (colour = "grey40" outside, never aes(colour = "grey40")).
  • More than ~6 colours: facet (facet_wrap(~ region)) or highlight one series instead.
  • Always ggsave() with explicit width, height, and dpi; default device size varies by machine.
  • ggplot2 4.0 moved internals to S7 and validates element arguments eagerly. Plain ggplot() code is unaffected; extension code poking at internals (ggproto hacks, theme list surgery) may break, so pin the version in renv before upgrading.

Reports: Quarto first

---
title: "Monthly revenue"
format:
  html:
    embed-resources: true
    code-fold: true
execute:
  echo: false
  warning: false
params:
  month: "2026-09"
---
  • Render with quarto render report.qmd -P month:2026-10; R Markdown (rmarkdown::render) is still fine for existing .Rmd projects.
  • Keep heavy computation out of the report: compute in a script or targets pipeline, save .rds/parquet, and read results in the document.
  • Set set.seed() in the setup chunk for anything random. Use chunk labels; they become figure file names and cross-reference ids (#| label: fig-revenue).
  • Use freeze: auto in _quarto.yml for websites/books so unchanged pages do not re-execute.

renv: reproducible packages

renv::init()            # once per project: creates renv.lock, .Rprofile, renv/
renv::install("dplyr")  # or install.packages() inside the project
renv::snapshot()        # write current versions to renv.lock; commit it
renv::restore()         # on a new machine or CI
renv::status()          # lockfile vs library drift
  • Commit renv.lock, .Rprofile, renv/activate.R, renv/settings.json; ignore renv/library/.
  • In CI, cache the renv cache directory keyed on the hash of renv.lock.
  • Use Posit Public Package Manager binary URLs on Linux to avoid compiling from source.
  • Record the R version: renv stores it in the lockfile; match it with rig or rv locally.

Performance escape hatches

SituationMove to
> ~10M rows in memory, many group-bysdata.table or dtplyr (dplyr syntax, data.table engine)
Larger than memory, columnar filesarrow datasets or duckdb + dbplyr
Many independent model fitsfurrr/mirai parallel map
Repeated pipelines with cachingtargets

For translating between dplyr, base R, and data.table idioms (when inheriting code or optimizing), read references/base-r-datatable.md.

Gotchas

  • read_csv guesses types from the first 1000 rows; a late non-numeric value becomes NA with a parsing problem. Always give col_types for production reads.
  • Factors: factor() sorts levels alphabetically; use forcats::fct_infreq, fct_reorder, fct_relevel to control plot order.
  • n_distinct() counts NA as a value unless na.rm = TRUE.
  • mean() on a vector with any NA returns NA — the classic empty plot or blank summary cell.
  • summarise() returning several rows per group is deprecated behavior; use reframe().
  • Floating point equality in filter(x == 0.3) fails; use dplyr::near().
  • Time zones: lubridate::ymd_hms() defaults to UTC; pass tz = explicitly and convert with with_tz() for display.
  • T and F can be overwritten; write TRUE and FALSE.
  • Do not rbind inside a loop; collect a list and list_rbind() once.

Verification

  1. lintr::lint_dir() and styler::style_dir() (or Air formatter) clean.
  2. Key assertions pass: no duplicate keys, expected row counts after joins (stopifnot(nrow(joined) == nrow(customers)) for many-to-one enrichment).
  3. Restart R (Ctrl+Shift+F10 / R --vanilla -e 'source("analysis.R")') and run end to end; hidden global state is the usual cause of “works on my machine”.
  4. quarto render succeeds from a clean clone after renv::restore().
  5. For package code: devtools::check() with 0 errors, 0 warnings, 0 notes; tests with testthat::test_dir().

Bundled files

Loaded only when a step in the skill needs them. Review them here before installing.

references/base-r-datatable.md 67 lines · 4.0 KB
# dplyr / base R / data.table translation

Read when inheriting base-R or data.table code, when a dplyr pipeline is too slow, or when
package code must avoid tidyverse dependencies. Behavior differences are noted where they
bite; verify semantics with a small test frame before swapping engines.

## Core verbs

| Task | dplyr | base R | data.table (`dt <- as.data.table(df)`) |
|---|---|---|---|
| Filter rows | `filter(df, x > 1, !is.na(y))` | `df[df$x > 1 & !is.na(df$y), ]` | `dt[x > 1 & !is.na(y)]` |
| Select columns | `select(df, a, b)` | `df[, c("a", "b")]` | `dt[, .(a, b)]` or `dt[, c("a","b")]` |
| Add column | `mutate(df, z = x * 2)` | `df$z <- df$x * 2` | `dt[, z := x * 2]` (by reference) |
| Sort | `arrange(df, g, desc(x))` | `df[order(df$g, -df$x), ]` | `setorder(dt, g, -x)` |
| Group summary | `summarise(df, m = mean(x), .by = g)` | `aggregate(x ~ g, df, mean)` | `dt[, .(m = mean(x)), by = g]` |
| Count | `count(df, g)` | `table(df$g)` / `as.data.frame(table(df$g))` | `dt[, .N, by = g]` |
| Distinct | `distinct(df, a, b)` | `unique(df[c("a","b")])` | `unique(dt, by = c("a","b"))` |
| Rename | `rename(df, new = old)` | `names(df)[names(df) == "old"] <- "new"` | `setnames(dt, "old", "new")` |
| Left join | `left_join(a, b, by = join_by(id))` | `merge(a, b, by = "id", all.x = TRUE)` | `b[a, on = "id"]` |
| Inner join | `inner_join(a, b, by = join_by(id))` | `merge(a, b, by = "id")` | `a[b, on = "id", nomatch = NULL]` |
| Anti join | `anti_join(a, b, by = join_by(id))` | `a[!a$id %in% b$id, ]` | `a[!b, on = "id"]` |
| Rolling join | `join_by(id, closest(t >= t2))` | (no direct equivalent) | `b[a, on = .(id, t2 = t), roll = TRUE]` |
| Pivot longer | `pivot_longer()` | `reshape(direction = "long")` | `melt(dt, id.vars =, measure.vars =)` |
| Pivot wider | `pivot_wider()` | `reshape(direction = "wide")` | `dcast(dt, id ~ key, value.var = "v")` |
| Window: lag | `mutate(l = lag(x), .by = g)` | `ave(x, g, FUN = \(v) c(NA, head(v, -1)))` | `dt[, l := shift(x), by = g]` |
| Row number in group | `mutate(i = row_number(), .by = g)` | `ave(seq_along(g), g, FUN = seq_along)` | `dt[, i := seq_len(.N), by = g]` |

## Semantics that differ

- **NA in filters**: `filter()` and `dt[cond]` drop rows where the condition is `NA`.
  Base `df[cond, ]` keeps them as all-`NA` rows. Wrap with `which()` in base:
  `df[which(df$x > 1), ]`.
- **Reference semantics**: data.table `:=`, `set*()` functions modify in place, including
  through copies made by `<-`. Use `copy(dt)` before mutating a table someone else holds.
- **Join direction**: `X[Y, on =]` keeps all rows of `Y` (it is a right join from X's point
  of view). Read it as "look up Y's keys in X".
- **merge() sorting**: `merge()` sorts by the key by default (`sort = TRUE`), changing row
  order; dplyr joins preserve the left table's order.
- **Drop = TRUE**: `df[, "a"]` on a data.frame returns a vector; use `df[, "a", drop = FALSE]`
  or `df["a"]` to keep a data frame. Tibbles never drop.
- **Partial matching**: `df$am` can partial-match `df$amount` on data.frames; tibbles warn
  and return `NULL`.
- **Factor conversion**: base `read.csv` before R 4.0 created factors from strings
  (`stringsAsFactors`). Old scripts may rely on this.

## When to switch engines

| Signal | Action |
|---|---|
| Group-by on tens of millions of rows takes minutes | Try `dtplyr::lazy_dt(df)` first (keep dplyr syntax), then native data.table |
| Data does not fit in RAM | `arrow::open_dataset()` or `duckdb` via `dbplyr`; never read whole into memory |
| Package must have minimal dependencies | base R or data.table (single dependency, no compiled tidyverse stack) |
| Code is read more than run | stay with dplyr |

## Benchmarking a swap

```r
bench::mark(
  dplyr = df |> summarise(m = mean(x), .by = g),
  dt    = as.data.table(df)[, .(m = mean(x)), by = g],
  check = FALSE,   # results differ in class/order; compare values separately
  iterations = 20
)
```

Compare values after sorting both results by the grouping key; a faster wrong answer is
not a speedup.

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc