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.
- name
r-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
- Choosing or interpreting a hypothesis test — use statistical-testing
- The analysis lives in Python with pandas or polars — use python-modern
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.
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 inDESCRIPTION. In scripts,library()calls go at the top, neverrequire()mid-script. - Never
setwd()in a script. Use project-relative paths withhere::here("data", "x.csv")or a Quarto/RStudio project root. filter()dropsNArows 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)
| Source | Use |
|---|---|
| CSV/TSV | readr::read_csv / read_tsv (or vroom for many files at once) |
| Excel | readxl::read_excel(sheet =, range =) |
| Parquet / larger-than-memory | arrow::open_dataset() then dplyr verbs, collect() at the end |
| SQL database | DBI::dbConnect() + dplyr::tbl(); inspect with show_query() before collect() |
| SPSS/Stata/SAS | haven::read_*, then haven::as_factor() on labelled columns |
dplyr: verbs that predict their row count
| Verb | Rows 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) overgroup_by() |> ... |> ungroup(). Leftover grouping is the most common silent bug in dplyr code; if you do usegroup_by(), end withungroup()orsummarise(.groups = "drop"). case_when()for multi-branch logic; the fallback is.default =notTRUE ~.if_else()over baseifelse()(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, treatingNAas not matching) andwhen_any()/when_all(); checkpackageVersion("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_widerproducing list-columns with a warning means duplicate id rows; fix the ids (or passvalues_fn) rather than ignoring the warning.complete()makes implicit missing combinations explicit (zero-sales months);fill()carries values down;separate_wider_delim()replacesseparate().nest()+purrr::map()+unnest()for per-group models; for simple per-group summariessummarise(.by =)is faster and clearer.
purrr and functional iteration
map()family overforloops that grow vectors. Use typed variants:map_dbl,map_chr,map_lgl;list_rbind()replacesmap_dfr().- Anonymous functions:
\(x) x + 1(R ≥ 4.1) instead of~ .x + 1in new code. - Wrap fallible calls with
possibly()orsafely()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)insideselect()/across()for character vectors. Neverget()oreval(parse()).- In packages, refer to columns as
.data$coland.env$varto 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(notsize) for lines and borders since 3.4.- Put mappings in
aes(), constants outside it (colour = "grey40"outside, neveraes(colour = "grey40")). - More than ~6 colours: facet (
facet_wrap(~ region)) or highlight one series instead. - Always
ggsave()with explicit width, height, anddpi; 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 (ggprotohacks,themelist 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.Rmdprojects. - Keep heavy computation out of the report: compute in a script or
targetspipeline, 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: autoin_quarto.ymlfor 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; ignorerenv/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
rigorrvlocally.
Performance escape hatches
| Situation | Move to |
|---|---|
| > ~10M rows in memory, many group-bys | data.table or dtplyr (dplyr syntax, data.table engine) |
| Larger than memory, columnar files | arrow datasets or duckdb + dbplyr |
| Many independent model fits | furrr/mirai parallel map |
| Repeated pipelines with caching | targets |
For translating between dplyr, base R, and data.table idioms (when inheriting code or optimizing), read references/base-r-datatable.md.
Gotchas
read_csvguesses types from the first 1000 rows; a late non-numeric value becomesNAwith a parsing problem. Always givecol_typesfor production reads.- Factors:
factor()sorts levels alphabetically; useforcats::fct_infreq,fct_reorder,fct_relevelto control plot order. n_distinct()countsNAas a value unlessna.rm = TRUE.mean()on a vector with anyNAreturnsNA— the classic empty plot or blank summary cell.summarise()returning several rows per group is deprecated behavior; usereframe().- Floating point equality in
filter(x == 0.3)fails; usedplyr::near(). - Time zones:
lubridate::ymd_hms()defaults to UTC; passtz =explicitly and convert withwith_tz()for display. TandFcan be overwritten; writeTRUEandFALSE.- Do not
rbindinside a loop; collect a list andlist_rbind()once.
Verification
lintr::lint_dir()andstyler::style_dir()(or Air formatter) clean.- Key assertions pass: no duplicate keys, expected row counts after joins
(
stopifnot(nrow(joined) == nrow(customers))for many-to-one enrichment). - 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”. quarto rendersucceeds from a clean clone afterrenv::restore().- For package code:
devtools::check()with 0 errors, 0 warnings, 0 notes; tests withtestthat::test_dir().
Bundled files
Loaded only when a step in the skill needs them. Review them here before installing.
references/base-r-datatable.md
# 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.