Tidyverse workflow: dplyr and tidyr
Chain verbs with the native pipe, group and summarise correctly, reshape between wide and long, and join tables without duplicating rows by accident.
The core verbs
library(dplyr)
orders |>
filter(status != "cancelled", amount > 0) |>
select(id, region, amount, created_at) |>
mutate(month = format(created_at, "%Y-%m"),
amount_gbp = amount * 0.79) |>
summarise(revenue = sum(amount_gbp),
orders = n(),
avg = mean(amount_gbp),
.by = c(region, month)) |>
arrange(desc(revenue)) |>
slice_head(n = 10)| Verb | Does | Base R equivalent |
|---|---|---|
filter | Keeps rows matching a condition | subset |
select / rename | Chooses or renames columns | df[, cols] |
mutate | Adds or changes columns | transform |
summarise | Reduces to one row per group | aggregate |
arrange | Sorts | order |
.by | Groups for one operation only | split plus lapply |
left_join | Adds columns from another table | merge |
.by is the modern replacement for group_by plus ungroup: the grouping exists only for that one verb, so you cannot forget to remove it.
Reshaping with tidyr
library(tidyr)
wide <- tibble(id = 1:2, jan = c(10, 20), feb = c(11, 21))
long <- wide |>
pivot_longer(cols = c(jan, feb), names_to = "month", values_to = "value")
back <- long |>
pivot_wider(names_from = month, values_from = value)
# splitting a combined column and handling missing values
messy <- tibble(code = c("a-1", "b-2", "c-3"))
messy |>
separate_wider_delim(code, delim = "-", names = c("letter", "number")) |>
drop_na()pivot_longeris for reading data you can group by a key column;pivot_wideris for reporting one column per category.- Long is the shape almost every analysis verb wants; wide is the shape humans and spreadsheets want.
- Rows where the value is missing sometimes collapse into list columns on widening. Pass
values_fillto control what happens instead.
Joins
# check the cardinality before you join
customers |> count(customer_id) |> filter(n > 1)
orders |> count(customer_id) |> filter(n > 1)
joined <- orders |>
left_join(customers, by = "customer_id", relationship = "many-to-one") |>
anti_join(blacklist, by = "customer_id")
# which rows failed to match?
unmatched <- orders |> anti_join(customers, by = "customer_id")⚠️
A join that duplicates the left table silently doubles your totals. Declare the expected cardinality with
relationship = "many-to-one" so dplyr raises an error instead, and compare nrow() before and after to confirm the join did what you thought.FAQ
Should I use the native pipe or magrittr?
The native
|> needs R 4.1 or later and has no dependency. magrittr's %>% still offers the dot placeholder for passing a value into a different position; use it if you rely on that, otherwise the native pipe is the simpler choice.Why did my summarise return one row instead of one per group?
The grouping was lost, usually because
group_by was followed by an ungroup, or because .by was placed on a different verb than the summarise. List the grouping argument on the same call that reduces the rows.Related
Data manipulation with base R Reading and writing data
Last refreshed 2026-09-18.