2 September 2026
Grouping and summarizing a list in modern Java
Grouping and summarizing a list in modern Java
The pattern this replaces is a HashMap, a loop, a computeIfAbsent and
a second pass to total things up. groupingBy with a downstream collector
does all of it in one expression, and reads as what it means.
import java.util.*;
import java.util.stream.*;
import static java.util.stream.Collectors.*;
// A record: constructor, accessors, equals, hashCode and toString,
// for the price of one line (Java 16+).
record Order(String customer, String region, long pence) {}
List<Order> orders = List.of(
new Order("acme", "north", 1200),
new Order("acme", "north", 800),
new Order("brown", "south", 4500)
);
// Group and count
Map<String, Long> perRegion = orders.stream()
.collect(groupingBy(Order::region, counting()));
// Group and total - the downstream collector is the useful part
Map<String, Long> spendPerCustomer = orders.stream()
.collect(groupingBy(Order::customer, summingLong(Order::pence)));
// Group, then map each group to a list of something else
Map<String, List<String>> customersPerRegion = orders.stream()
.collect(groupingBy(Order::region, mapping(Order::customer, toList())));
// Sorted output, because groupingBy returns a HashMap with no order
Map<String, Long> sorted = orders.stream()
.collect(groupingBy(Order::region, TreeMap::new, summingLong(Order::pence)));
The parts that catch people
groupingBy returns a HashMap, so the order is undefined. If the
output is going anywhere a person will read, pass TreeMap::new as the
second argument as in the last example - otherwise the order changes
between runs and looks like a bug.
The downstream collector is the whole point. groupingBy(fn) alone
gives you Map<K, List<T>>, which usually means a second pass to reduce
each list. counting(), summingLong(), averagingDouble(),
mapping() and toSet() all fit in that second slot and do it in one.
Collectors.toList() gives no guarantee of mutability;
Stream.toList() (Java 16+) is explicitly unmodifiable. If something
downstream adds to that list, the difference is an
UnsupportedOperationException at runtime rather than a compile error.
A record is not a bean. No setters, no no-arg constructor - which is what makes it right for a value passing through a stream, and wrong for anything a framework wants to instantiate reflectively and populate.