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)
VerbDoesBase R equivalent
filterKeeps rows matching a conditionsubset
select / renameChooses or renames columnsdf[, cols]
mutateAdds or changes columnstransform
summariseReduces to one row per groupaggregate
arrangeSortsorder
.byGroups for one operation onlysplit plus lapply
left_joinAdds columns from another tablemerge

.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_longer is for reading data you can group by a key column; pivot_wider is 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_fill to 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.

Data manipulation with base R Reading and writing data

Last refreshed 2026-09-18.