Grouping and partitioning with Collectors
The map lesson in module 5 built a Map<String, List<String>> with
computeIfAbsent and a loop. This lesson replaces that loop with one line, and
then does five more things the loop version would each need its own loop for.
Collectors is where streams stop being a tidier for and start being a
reporting language.
groupingBy
Map<String, List<Sub>> byArea = subs.stream().collect(Collectors.groupingBy(Sub::area));
Wagholi -> [Priya, Kavita]
Bavdhan -> [Rahul]
Kharadi -> [Arjun, Amit]
One line. The function you pass decides the key; everything with the same key lands in the same list.
Downstream collectors
The second argument says what to do with each group instead of listing it. This is the part worth learning properly, because it is where most real reports live.
subs.stream().collect(groupingBy(Sub::area, counting()));
subs.stream().collect(groupingBy(Sub::area, summingInt(Sub::tiffins)));
subs.stream().collect(groupingBy(Sub::area, averagingInt(Sub::tiffins)));
subs.stream().collect(groupingBy(Sub::area, mapping(Sub::name, toList())));
subs.stream().collect(groupingBy(Sub::area, mapping(Sub::name, joining(", "))));
{Wagholi=2, Bavdhan=1, Kharadi=2}
{Wagholi=56, Bavdhan=22, Kharadi=18}
{Wagholi=28.0, Bavdhan=22.0, Kharadi=9.0}
{Wagholi=[Priya, Kavita], Bavdhan=[Rahul], Kharadi=[Arjun, Amit]}
{Wagholi=Priya, Kavita, Bavdhan=Rahul, Kharadi=Arjun, Amit}
Five reports, five lines. The loop versions are five loops.
| Downstream | Gives |
|---|---|
counting() |
Long per group |
summingInt(f) / summingLong(f) |
Total per group |
averagingInt(f) / averagingDouble(f) |
Double per group |
summarizingInt(f) |
Count, sum, min, average, max in one |
mapping(f, downstream) |
Transform each element, then collect |
filtering(p, downstream) |
Keep some, then collect — Java 9+ |
joining(sep) |
One string per group |
toSet() |
Deduplicated per group |
maxBy(cmp) / minBy(cmp) |
Optional per group |
reducing(...) |
Anything else |
collectingAndThen(c, f) |
Collect, then transform the result |
mapping is the one to remember. "Group by area, but I only want the names" is
mapping(Sub::name, toList()), and that shape covers most of what you will be
asked for.
Controlling the map type
groupingBy gives you a HashMap, whose order is arbitrary — the problem the
collections module warned about. A three-argument form fixes it:
Map<String, Long> sorted = subs.stream()
.collect(Collectors.groupingBy(Sub::area, TreeMap::new, Collectors.counting()));
{Bavdhan=1, Kharadi=2, Wagholi=2}
Areas in alphabetical order, which is what a printed report wants.
One practical note: that form needs a target type to infer against. Passing it
straight into System.out.println(...) fails with inference variable R has
incompatible bounds, because println is overloaded and the compiler has
nothing to aim at. Assign it to a Map<String, Long> variable first. The error
is long and alarming and the fix is one line.
Two levels
The downstream can itself be a groupingBy:
Map<String, Map<String, Long>> twoLevel = subs.stream().collect(
Collectors.groupingBy(Sub::area, TreeMap::new,
Collectors.groupingBy(Sub::plan, Collectors.counting())));
{Bavdhan={jain=1}, Kharadi={veg=1, jain=1}, Wagholi={veg=2}}
Plans per area, counted. Three levels is possible and is where you should stop — the nesting stops being readable and a small record with a two-field key is clearer.
partitioningBy
When the grouping is a yes-or-no question:
Map<Boolean, List<Sub>> active = subs.stream()
.collect(Collectors.partitioningBy(s -> s.tiffins() > 0));
active : [Priya, Arjun, Kavita, Rahul]
inactive : [Amit]
partitioningBy always has both keys, even when one side is empty — unlike
groupingBy with a boolean function, where a missing key returns null. That
guarantee is the reason it exists, and it is worth using rather than grouping on
a predicate.
It takes a downstream too: partitioningBy(p, counting()).
toMap, and its sharp edge
subs.stream().collect(Collectors.toMap(Sub::name, Sub::tiffins));
{Rahul=22, Priya=26, Amit=0, Arjun=18, Kavita=30}
Fine, because names are unique here. Key on something that is not:
subs.stream().collect(Collectors.toMap(Sub::area, Sub::tiffins));
IllegalStateException: Duplicate key Wagholi (attempted merging values 26 and 30)
toMap throws on a duplicate key by default, which is a good design — it
refuses to silently drop data. Supply a merge function to say what should happen:
subs.stream().collect(Collectors.toMap(Sub::area, Sub::tiffins, Integer::sum));
{Wagholi=56, Bavdhan=22, Kharadi=18}
And a fourth argument picks the map type:
Map<String, Integer> sortedTotals = subs.stream()
.collect(Collectors.toMap(Sub::area, Sub::tiffins, Integer::sum, TreeMap::new));
{Bavdhan=22, Kharadi=18, Wagholi=56}
One more trap: toMap throws NullPointerException if the value function
returns null, even though HashMap allows null values. Use
groupingBy/reducing if nulls are genuinely possible, or fix the data.
Top of each group
The pattern you will want and not immediately find:
Map<String, String> topPerArea = subs.stream().collect(
Collectors.groupingBy(Sub::area, TreeMap::new,
Collectors.collectingAndThen(
Collectors.maxBy(Comparator.comparingInt(Sub::tiffins)),
o -> o.map(Sub::name).orElse("none"))));
{Bavdhan=Rahul, Kharadi=Arjun, Wagholi=Kavita}
maxBy returns an Optional per group — it has to, because a collector must
work for an empty group — and collectingAndThen unwraps it. A group reaching
that code is never actually empty, which is why orElse("none") never fires, but
the type system does not know that.
This is about as complex as a collector should get. Beyond it, collect the groups and post-process with an ordinary loop — clarity beats a single expression.
When not to use a collector
- When one loop does three things. Building three different maps in one pass is one loop or three streams. Three streams is usually clearer and three passes over a list is rarely the bottleneck — but say so deliberately rather than by accident.
- When the key computation is expensive.
groupingBycalls the classifier once per element; that is fine. ButgroupingBy(s -> lookupArea(s))hitting a database per element is a loop in disguise. - When the nesting exceeds two levels. Collect once, then transform.
Check your work
What does the second argument to groupingBy do? Replaces the default
toList() with any other collector, so each group is summarised rather than
listed.
How do you group by area but collect only the names?
groupingBy(Sub::area, mapping(Sub::name, toList())).
How do you get the groups in sorted key order? The three-argument form:
groupingBy(Sub::area, TreeMap::new, downstream).
Why might that form fail to compile inside a println? There is no target
type to infer against, and println is overloaded. Assign it to a typed variable
first.
What does partitioningBy guarantee that groupingBy on a boolean does
not? Both true and false keys always exist, even when one side is empty.
What does toMap do with a duplicate key? Throws IllegalStateException: Duplicate key ... (attempted merging values ... and ...). Supply a merge
function to decide.
What does toMap do with a null value? Throws NullPointerException, even
though HashMap would allow it.
Why does maxBy return an Optional inside groupingBy? A collector must be
defined for an empty group. collectingAndThen unwraps it.
Practice 2, the five reports. Counts {Wagholi=2, Bavdhan=1, Kharadi=2},
totals {Wagholi=56, Bavdhan=22, Kharadi=18}, averages {Wagholi=28.0, Bavdhan=22.0, Kharadi=9.0}, names {Wagholi=[Priya, Kavita], ...}, joined names
{Wagholi=Priya, Kavita, ...}. Note the average for Kharadi is 9.0, not 18 —
Amit's zero counts, which is a real reporting decision rather than a bug. If you
want the average among active subscribers, filtering is the downstream you
need.
Practice 4, the duplicate key. toMap(Sub::area, Sub::tiffins) throws
IllegalStateException: Duplicate key Wagholi (attempted merging values 26 and 30). Adding Integer::sum gives {Wagholi=56, ...}. Adding
(a, b) -> a would keep the first and silently discard Kavita's 30 — which
compiles, runs, and produces a wrong report. Choosing the merge function is a
business decision, not a syntax detail.
Practice
-
Group and print. Group subscribers by area and print each area with its members.
-
Five one-liners. Count per area; total tiffins per area; average tiffins per area; names per area as a list; names per area joined with commas. Predict each before running.
-
Sort the report. Redo the count-per-area with a
TreeMap. Then try to pass it directly toprintlnand read the inference error. -
Break
toMap. Key on area with no merge function and read the exception. Then fix it three ways — sum, keep-first, keep-last — and say what each one would mean on a real invoice. -
Partition. Split subscribers into active and inactive, then do it again with
groupingByon the same predicate and empty input. Note which one still has both keys. -
Harder — a month-end report. From a list of delivery rows (
date, customer, area, tiffins) produce, each as one collector: total tiffins per customer sorted by name; total revenue per area in paise sorted descending; the busiest day overall; the top customer in each area; and the customers who took nothing after the 15th. Then print it as a formatted table with aligned columns. This is the capstone's report, written early.
Next: practice — converting real loops, and deciding when not to.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship