Counting Rows per Group: table()
The simplest group-by question is "how many of each?" - and base R answers it in one call. table() counts how often each value appears:
One column gives counts per value; two columns give a full cross-tab (regions as rows, plans as columns). For "what share?" wrap it in prop.table(table(...)). table() is the fastest route to a frequency answer in all of R - don't reach for anything heavier when a count is all you need.
One Statistic per Group: tapply()
tapply(values, groups, function) splits the first vector by the second and applies the function to each piece:
The result is a named vector - group labels as names, statistics as values - which makes it perfect for quick lookups (means["EU"]). The name decodes as "table apply": apply a function along a grouping table. It belongs to the same family as sapply() and lapply(), covered in the apply family doc.
tapply()'s limit is shape: a named vector is awkward to merge, plot, or keep processing. When the summary is a stepping stone rather than the final answer, you want a data frame back - which is exactly what the next tool returns.
The Formula Workhorse: aggregate()
aggregate() is base R's full group-by: it returns a data frame, and its formula interface reads like the sentence you would say. value ~ group means "value, broken down by group":
Adding groupers is just + another_column in the formula - the second call sums per region and quarter combination. You can also aggregate several value columns at once with cbind(a, b) ~ group. Because the output is an ordinary data frame, it slots straight into a merge, a sort, or a plot - this is the base tool to default to for grouped summaries.
One quiet behavior to know: the formula interface drops rows with NA in any used column before aggregating. Usually what you want; occasionally the explanation for a count that looks low.
The dplyr Way: group_by() + summarize()
The modern standard is dplyr's two-verb pattern - group_by() declares the grouping, summarize() collapses each group to one row, computing as many statistics as you name (static; the sandbox runs base R only):
library(dplyr)
sales |>
group_by(region) |>
summarize(
n = n(),
total = sum(amount),
avg = mean(amount)
)
This is where dplyr genuinely outshines base R: multiple statistics per group in one readable call - aggregate() needs contortions for that - plus n() for free, and the result flows straight into the next pipe. (summarise() is the same function, UK spelling.)
One thing that puzzles newcomers: with several groupers, summarize() peels off only the last one, leaving the result still grouped - and prints a message about it. Say what you want explicitly with the .groups argument: summarize(avg = mean(amount), .groups = "drop") returns a plain ungrouped frame, which is the right default habit. A grouped frame that sneaks into later code makes mutate() and friends silently operate per group - a classic source of confusing results.
Choosing Between Them
- A count -
table(), nothing beats one call. - One statistic, quick look -
tapply(), and read the answer off the named vector. - Data frame out, base-only environment -
aggregate()with a formula. - Several statistics, part of a pipeline, or anything ambitious -
group_by() |> summarize().
There is no wrong answer among them - they all compute the same numbers - but matching the tool to the output shape you need saves the conversion step afterwards. Averages, medians, and spreads themselves are covered in descriptive statistics.
Worked Example: mtcars by Cylinder Count
Everything together on a built-in dataset - per cylinder count, how many cars, and their average fuel economy and horsepower:
Eleven 4-cylinder cars averaging about 26.7 mpg, seven 6-cylinder around 19.7, fourteen 8-cylinder around 15.1 - and horsepower marching the opposite direction. Two calls, a whole summary table: this is the kind of question grouped aggregation exists to answer.
What You Take Away
table()for counts,prop.table()for shares.tapply(values, groups, fn)returns a named vector - quick looks, easy lookups.aggregate(value ~ group, data, FUN)returns a data frame;+adds groupers,cbind()adds value columns.- dplyr's
group_by() |> summarize(n = n(), avg = mean(x), .groups = "drop")is the modern standard for multi-statistic summaries. - Pick by output shape; they all agree on the numbers.
Next up: combining tables that share a key - merge() and the dplyr join family.
Frequently Asked Questions
How do I calculate the mean by group in R?
Base R has two tools: tapply(df$value, df$group, mean) returns a named vector of group means, and aggregate(value ~ group, data = df, FUN = mean) returns the same result as a data frame. In dplyr: df |> group_by(group) |> summarize(avg = mean(value)).
How do I count occurrences by group in R?
table(df$group) counts rows per group value in one call; table(df$a, df$b) cross-tabulates two columns. In dplyr, count(df, group) does the same and returns a data frame, which is easier to keep processing.
What does aggregate() do in R?
aggregate() splits a data frame by one or more grouping columns, applies a function to each piece, and returns a data frame of results. The formula interface reads naturally: aggregate(sales ~ region + quarter, data = df, FUN = mean) means sales, grouped by region and quarter, averaged.
What is the difference between tapply and aggregate?
Same computation, different output shape. tapply() returns a named vector (or array for two groupers) - handy for quick lookups. aggregate() returns a data frame - better when the result feeds further analysis, a merge, or a plot. When in doubt, aggregate().