dplyr with_groups() in R: Temporarily Group for One Expression
The with_groups() function in dplyr applies a TEMPORARY grouping for a single function call, then restores the original grouping. It is a scoped variant of group_by().
df |> with_groups(g, mutate, x = mean(value)) # group, mutate, restore df |> with_groups(g1, summarise, n = n()) # temp group then summarise df |> with_groups(NULL, fn, ...) # ungroup, run fn, restore df |> mutate(.by = g, x = mean(value)) # cleaner alternative (1.1+) df |> group_by(g) |> mutate(x = mean(value)) |> ungroup() # equivalent
Need explanation? Read on for examples and pitfalls.
What with_groups() does in one sentence
with_groups(.data, .groups, .f, ...) calls .f on .data after temporarily applying .groups, then RESTORES the original grouping. The result has whatever grouping .data had before the call.
This is dplyr's "scoped grouping" mechanism. It existed before the .by argument; for new code, .by is usually cleaner.
Syntax
with_groups(.data, .groups, .f, ...). .groups is the temporary grouping (column or NULL); .f is the verb to apply.
Equivalent to group_by(cyl) |> mutate(...) |> ungroup() but in one verb.
.by argument over with_groups(). It is more concise: mutate(df, .by = cyl, mpg_z = ...). Reserve with_groups for legacy code or dynamic-grouping patterns.Five common patterns
1. Temporary group + mutate
2. Temporary group + summarise
3. Ungroup temporarily
4. Modern .by alternative (preferred)
.by is shorter and more discoverable.
5. Dynamic grouping
For dynamic grouping, with_groups (or group_by(across(all_of(...)))) is needed because .by doesn't accept dynamic column names cleanly.
with_groups is "scoped grouping": pre-1.1 dplyr, this was the only way to have grouping affect ONE verb without leaking to subsequent verbs. dplyr 1.1's .by argument is the modern equivalent. with_groups still has its place for legacy code and dynamic grouping.with_groups() vs .by vs group_by()
Three ways to apply grouping in dplyr.
| Approach | Scope | Best for | ||
|---|---|---|---|---|
with_groups(g, fn, ...) |
One function call | Pre-1.1 scoped grouping | ||
mutate(.by = g, ...) |
One verb (1.1+) | Modern scoped grouping | ||
| `group_by(g) | > ... | > ungroup()` | Multi-verb | Long grouped pipelines |
When to use which:
.byfor one-verb scoped grouping in modern code.group_byfor multi-verb pipelines.with_groupsfor legacy code or dynamic grouping with non-standard evaluation.
A practical workflow
Use with_groups when you need to temporarily switch grouping mid-pipeline without breaking the chain.
Without with_groups, you'd need explicit ungroup/regroup gymnastics.
Common pitfalls
Pitfall 1: confusion between with_groups and group_by. with_groups is SCOPED (one call); group_by is PERSISTENT until ungroup. Mixing them up causes hard-to-diagnose bugs.
Pitfall 2: dplyr 1.1 deprecation soft-warning. with_groups isn't deprecated, but .by is recommended. Don't write new code with with_groups unless you have a reason.
with_groups arguments differ from typical dplyr functions: it expects .f second, then .... Easy to write df |> with_groups(g, mean(x)) (wrong) instead of df |> with_groups(g, summarise, x = mean(x)) (right).Why scoped grouping matters
The cleanest dplyr pipelines use scoped grouping (.by or with_groups) rather than persistent group_by/ungroup pairs. Persistent grouping is "stateful", every subsequent verb has to reason about whether the current grouping applies. Scoped grouping is "stateless", each verb has explicit grouping per call. For pipelines longer than 3 to 4 verbs, scoped grouping is much easier to debug because you can read each verb in isolation. The pre-1.1 pattern was group_by/ungroup; modern pipelines should default to .by. with_groups remains useful for legacy code or dynamic-grouping scenarios where .by's syntax falls short.
When to use over .by
For modern dplyr code, .by is always preferred over with_groups for static grouping. Reach for with_groups only in two specific cases: (1) maintaining legacy code consistency, and (2) when grouping columns are determined at runtime via !!sym() or across(all_of(...)). The .by argument has a small limitation around dynamic column name passing that with_groups handles cleanly.
Try it yourself
Try it: Use with_groups to compute per-cyl mean mpg as a new column, without changing mtcars's grouping. Save to ex_with_grp.
Click to reveal solution
Explanation: with_groups applies cyl grouping to the mutate call only. The .by argument does the same in modern dplyr.
Related dplyr functions
After mastering with_groups, look at:
group_by()/ungroup(): persistent grouping.byargument: modern scoped grouping (1.1+)cur_group(),cur_group_id(),cur_group_rows(): introspectiongroup_split(): split into list of frames per grouprowwise(): per-row groupingnest_join(): nested grouping with list columns
For most modern code, .by replaces with_groups; check if it suits your case first.
FAQ
What does with_groups do in dplyr?
with_groups(.data, .groups, .f, ...) applies a temporary grouping to .data, runs .f, and restores the original grouping. Scoped grouping for one function call.
What is the difference between with_groups and group_by?
group_by is PERSISTENT: subsequent verbs see the grouping until ungroup. with_groups is SCOPED: only the wrapped function sees the grouping; the original grouping is restored after.
Should I use with_groups or .by in modern code?
.by (dplyr 1.1+) is preferred for one-verb scoped grouping. It is shorter and more discoverable. Reserve with_groups for legacy code or dynamic grouping.
How do I temporarily ungroup with with_groups?
Pass NULL as the grouping: with_groups(NULL, fn, ...). The function runs ungrouped; the original grouping is restored after.
Is with_groups deprecated?
Not deprecated, but soft-superseded by .by in dplyr 1.1+. New code should usually use .by.