Menu

Group By and Summarize in R (aggregate, tapply, dplyr)

Every way to compute per-group statistics in R: counting with table(), tapply() for one stat per group, aggregate()'s formula interface, and dplyr's group_by() with summarize().

This page includes runnable editors - edit, run, and see output instantly.

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().

Coddy programming languages illustration

Learn to code with Coddy

GET STARTED