Quick summary
Summarize this blog with AI
Introduction
dplyr::group_by() changes the groups over which later verbs operate; it does not itself summarize rows. This lesson follows one small sales table through grouped summaries, grouped mutation and explicit ungrouping. The placement of .groups matters: it is a summarise argument, not a group_by option.
Tested with R 4.5.1 and dplyr 1.1.4. Run the following code blocks in order. All inputs are included.
Create groups and inspect their state
The table includes a missing amount and a missing region key. Keep these cases visible rather than treating them as zero or discarding them silently.
sales <- data.frame(region = c("East", "East", "West", "West", NA),
store = c("A", "A", "B", "C", "D"),
amount = c(10, 20, NA, 40, 5))
grouped <- dplyr::group_by(sales, region, store)
print(dplyr::group_vars(grouped)) # "region" "store"
stopifnot(nrow(grouped) == 5, dplyr::n_groups(grouped) == 4)
A missing grouping key forms its own group. Decide whether that is acceptable for the report. Merely grouping does not reorder the underlying records into a promised chronological order.
Put .groups on summarise
The summary counts records and observed amounts, and returns NA for a group with no observed amounts.
summary <- dplyr::summarise(grouped,
records = dplyr::n(), observed = sum(!is.na(amount)),
observed_total = if (all(is.na(amount))) NA_real_ else sum(amount, na.rm = TRUE),
.groups = "drop")
summary <- dplyr::arrange(summary, region, store)
print(as.data.frame(summary))
stopifnot(nrow(summary) == 4, length(dplyr::group_vars(summary)) == 0)
stopifnot(identical(summary$observed_total, c(30, NA_real_, 40, 5)))
The values are East/A 30, West/B NA, West/C 40 and missing-region/D 5. A partial observed total does not establish a complete total. Putting .groups = "drop_last" inside group_by would create a grouping column called .groups, not control the summary result.
Distinguish retained groups from ungrouped results
After summarizing two keys, drop_last retains the first key. Explicitly remove it before an operation intended to span all regions.
by_store <- dplyr::summarise(grouped, rows = dplyr::n(), .groups = "drop_last")
stopifnot(identical(dplyr::group_vars(by_store), "region"))
all_stores <- dplyr::ungroup(by_store)
stopifnot(length(dplyr::group_vars(all_stores)) == 0)
global <- dplyr::summarise(all_stores, stores = dplyr::n(), records = sum(rows))
stopifnot(global$stores == 4, global$records == 5)
Use .groups = "keep" when the next step intentionally needs both group keys. Inspect group_vars() at a boundary where the intended scope changes; relying on a hidden grouped state can change later calculations.
Mutate keeps the original rows
A grouped mutation computes each region total and repeats it on that region’s rows. It has a different output shape from summarise.
with_totals <- sales |>
dplyr::group_by(region) |>
dplyr::mutate(region_observed_total = sum(amount, na.rm = TRUE)) |>
dplyr::ungroup()
stopifnot(nrow(with_totals) == nrow(sales))
print(with_totals$region_observed_total) # 30 30 40 40 5
stopifnot(identical(with_totals$region_observed_total, c(30, 30, 40, 40, 5)))
empty <- sales[FALSE, ] |>
dplyr::group_by(region) |>
dplyr::summarise(rows = dplyr::n(), .groups = "drop")
stopifnot(nrow(empty) == 0)
Each displayed region has at least one observed amount; use an explicit all-missing policy for broader input. For factors, group_by(..., .drop = FALSE) can preserve unused levels as empty groups. Keep grouping, missingness and output order explicit before considering performance changes.