RizTech Academy logo
RizTech Academy
Streams and Functional JavaLesson 4 of 530 min

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. groupingBy calls the classifier once per element; that is fine. But groupingBy(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

  1. Group and print. Group subscribers by area and print each area with its members.

  2. 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.

  3. Sort the report. Redo the count-per-area with a TreeMap. Then try to pass it directly to println and read the inference error.

  4. 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.

  5. Partition. Split subscribers into active and inactive, then do it again with groupingBy on the same predicate and empty input. Note which one still has both keys.

  6. 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