Group, partition, join, and aggregate with Collectors — the power tools of streams.
Published September 21, 2026
A stream's terminal operation collect(...) needs to know what to build from the elements: a list, a set, a map, a string, a number. That recipe is a Collector, and the Collectors class provides ready-made ones. Most of the time you need toList(). The real power is in the grouping collectors, which do in one line what used to take a loop, a map and several if statements.
// Before streams: count orders per status by hand
Map<Status, Integer> counts = new HashMap<>();
for (Order o : orders) {
counts.merge(o.status(), 1, Integer::sum);
}
// With a collector
Map<Status, Long> counts = orders.stream()
.collect(Collectors.groupingBy(Order::status, Collectors.counting()));
The examples below use this record:
record Order(String id, String customer, String city, Status status, BigDecimal total, int items) {}
List<String> ids = orders.stream().map(Order::id).collect(Collectors.toList());
Set<String> cities = orders.stream().map(Order::city).collect(Collectors.toSet());
String csv = orders.stream().map(Order::id).collect(Collectors.joining(", ", "[", "]"));
long count = orders.stream().collect(Collectors.counting()); // same as .count()
Collectors.toList() makes no promise about mutability; today it returns an ArrayList. Stream.toList() (Java 16) and Collectors.toUnmodifiableList() return unmodifiable lists.
groupingBy: split into buckets by a keyMap<String, List<Order>> byCity = orders.stream()
.collect(Collectors.groupingBy(Order::city));
// { "Pune" = [o1, o4], "Delhi" = [o2], "Mumbai" = [o3, o5] }
groupingBy(classifier) applies the classifier to each element to get a key, and collects the elements with the same key into a list. Only keys that actually occur appear in the map.
The second argument decides what each group becomes, instead of a list. This is where most interview questions live:
// How many orders per city?
Map<String, Long> countByCity =
orders.stream().collect(groupingBy(Order::city, counting()));
// Total revenue per city
Map<String, BigDecimal> revenueByCity =
orders.stream().collect(groupingBy(Order::city,
reducing(BigDecimal.ZERO, Order::total, BigDecimal::add)));
// Average items per order, per status
Map<Status, Double> avgItems =
orders.stream().collect(groupingBy(Order::status, averagingInt(Order::items)));
// Distinct customers per city
Map<String, Set<String>> customersByCity =
orders.stream().collect(groupingBy(Order::city, mapping(Order::customer, toSet())));
// Largest order per city
Map<String, Optional<Order>> biggest =
orders.stream().collect(groupingBy(Order::city, maxBy(comparing(Order::total))));
// Two levels: city → status → count
Map<String, Map<Status, Long>> cityStatus =
orders.stream().collect(groupingBy(Order::city, groupingBy(Order::status, counting())));
(These examples assume import static java.util.stream.Collectors.*; and import static java.util.Comparator.comparing;, which is common in stream-heavy code.)
Useful downstream collectors: counting(), summingInt/Long/Double, averagingInt/…, mapping(f, downstream), filtering(pred, downstream) (Java 9), flatMapping (Java 9), minBy/maxBy, reducing, collectingAndThen(downstream, finisher), and another groupingBy.
By default you get a HashMap, with no key order. Pass a map factory as the middle argument when order matters:
TreeMap<String, Long> sortedByCity = orders.stream()
.collect(groupingBy(Order::city, TreeMap::new, counting()));
maxBy returns an Optional, and collectingAndThen unwraps itmaxBy gives Optional<Order> because in general a group could be empty. It can't be in groupingBy, where every group has at least one element, so unwrap it:
Map<String, Order> biggest = orders.stream().collect(groupingBy(Order::city,
collectingAndThen(maxBy(comparing(Order::total)), Optional::get)));
partitioningBy: exactly two bucketsMap<Boolean, List<Order>> bigAndSmall = orders.stream()
.collect(partitioningBy(o -> o.total().compareTo(new BigDecimal("1000")) >= 0));
bigAndSmall.get(true); // orders ≥ 1000
bigAndSmall.get(false); // the rest
Unlike groupingBy with a boolean key, partitioningBy always contains both true and false keys, even when one list is empty, which saves null checks. It also accepts a downstream collector (partitioningBy(pred, counting())).
toMap: one entry per elementMap<String, Order> byId = orders.stream().collect(toMap(Order::id, Function.identity()));
Two traps:
toMap throws IllegalStateException: Duplicate key. When duplicates are possible, pass a merge function that says which value wins, or how to combine them:
Map<String, BigDecimal> spendByCustomer = orders.stream()
.collect(toMap(Order::customer, Order::total, BigDecimal::add)); // sum on collision
NullPointerException (it uses Map.merge internally). Filter out nulls first, or use groupingBy.Rule of thumb: one value per key → toMap (with a merge function if keys can repeat); many values per key → groupingBy.
IntSummaryStatistics stats = orders.stream().collect(summarizingInt(Order::items));
stats.getMin(); stats.getMax(); stats.getAverage(); stats.getSum(); stats.getCount();
When you need two different results from one pass, teeing (Java 12) combines two collectors:
record Range(BigDecimal min, BigDecimal max) {}
Range range = orders.stream().collect(teeing(
minBy(comparing(Order::total)), maxBy(comparing(Order::total)),
(min, max) -> new Range(min.orElseThrow().total(), max.orElseThrow().total())));
groupingBy with a downstream collector is one pass over the data. That's more efficient than grouping into lists and then post-processing each list.GROUP BY) is usually better than loading every row into memory to group in Java.Q: What happens with toMap if two elements have the same key?
A: It throws IllegalStateException ("Duplicate key"). Supply a third argument, a merge function such as (a, b) -> a to keep the first or BigDecimal::add to combine, to resolve collisions deliberately.
Q: What's the difference between groupingBy(x -> x > 0) and partitioningBy(x -> x > 0)?
A: Both produce a Map<Boolean, ...>. partitioningBy always contains both keys, even if a partition is empty, and uses a specialized, slightly more efficient map. groupingBy only contains keys that occurred, so get(false) may return null.
Q: How do you get the groups sorted by key?
A: Pass a map factory: groupingBy(Order::city, TreeMap::new, toList()). For a sort by value (e.g. cities by order count), collect first, then stream the entries: map.entrySet().stream().sorted(Map.Entry.comparingByValue(Comparator.reverseOrder())).
Q: Is groupingBy thread-safe in a parallel stream?
A: Yes. The collector framework gives each thread its own partial map and merges them. groupingByConcurrent instead writes into one shared ConcurrentMap, which can be faster for parallel streams when the order of elements within each group doesn't matter.
Q: How would you find the most common city?
A: Count, then take the maximum entry: orders.stream().collect(groupingBy(Order::city, counting())).entrySet().stream().max(Map.Entry.comparingByValue()).map(Map.Entry::getKey). It's two passes, one over the orders and one over the much smaller map, which is fine.