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.