JAVA / STREAMS, LAMBDAS AND OPTIONAL
Grouping, partitioning and downstream collectors
Bucket elements with groupingBy or partitioningBy and shape each bucket with downstream collectors such as counting, mapping and filtering.
What you will learn
- Replace the default toList() downstream with counting(), summingInt() or mapping()
- Nest collectors: partitioningBy or groupingBy can serve as another group's downstream
- partitioningBy always has both false and true keys; groupingBy skips unseen keys
- Use groupingBy(classifier, TreeMap::new, downstream) to control the map type
Understanding Grouping, partitioning and downstream collectors
groupingBy takes a classifier function, applies it to every element and uses the result as a map key, so all elements sharing a key land in the same bucket. The one-argument form is pure shorthand: groupingBy(f) means groupingBy(f, toList()). Once you read that second argument as a replaceable recipe rather than a fixed behaviour, counting(), summingInt(), joining() and even another groupingBy become interchangeable choices for what a bucket becomes.
A Collector is four pieces of behaviour: a supplier that makes a container, an accumulator that adds one element, a combiner for parallel merges and a finisher that converts the container into the result. groupingBy calls the downstream's supplier once per new key, routes each element into its bucket's accumulator, and runs the downstream's finisher on every bucket at the end. That ordering is why classification happens before the downstream sees anything: Collectors.filtering(p, toList()) leaves an empty list under a key whose elements all failed p, while .filter(p) earlier in the pipeline means that key is never created. The same nesting explains the value types you get, since counting() finishes as Long and maxBy() finishes as Optional.
partitioningBy is the two-bucket special case driven by a Predicate. It returns a Map<Boolean, D> that hashes nothing: two containers are created up front, so both false and true are always present, and the side that received no elements still gets the downstream's result for an empty container. groupingBy(t -> test(t)) looks equivalent but only creates keys it actually observes, so get(true) can be null and any report that assumes two halves will break. groupingBy also rejects a null key with a NullPointerException, while a predicate cannot produce one, which makes partitioningBy the safer tool for a yes/no split.
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.stream.Collectors;
public class Main {
record Employee(String name, String dept, int salary) {}
public static void main(String[] args) {
List<Employee> staff = List.of(
new Employee("Ada", "eng", 120),
new Employee("Bo", "eng", 100),
new Employee("Cy", "sales", 70),
new Employee("Di", "sales", 90),
new Employee("Ed", "hr", 60));
Map<String, List<String>> namesByDept = staff.stream().collect(
Collectors.groupingBy(Employee::dept, TreeMap::new,
Collectors.mapping(Employee::name, Collectors.toList())));
Map<String, Long> headcount = staff.stream().collect(
Collectors.groupingBy(Employee::dept, TreeMap::new, Collectors.counting()));
Map<String, Integer> payroll = staff.stream().collect(
Collectors.groupingBy(Employee::dept, TreeMap::new,
Collectors.summingInt(Employee::salary)));
Map<Boolean, List<String>> senior = staff.stream().collect(
Collectors.partitioningBy(e -> e.salary() >= 100,
Collectors.mapping(Employee::name, Collectors.toList())));
System.out.println(namesByDept);
System.out.println(headcount);
System.out.println(payroll);
System.out.println(senior);
}
}groupingBy and partitioningBy decide only which bucket an element falls into; the downstream collector decides what each bucket turns into.
Worked examples
Where the filter goes changes the keys
Filtering upstream deletes whole groups, while Collectors.filtering keeps the key with an empty bucket.
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.stream.Collectors;
public class Main {
public static void main(String[] args) {
List<String> words = List.of("ant", "arc", "bee", "bird", "cow");
Map<Character, List<String>> filteredFirst = words.stream()
.filter(w -> w.length() > 3)
.collect(Collectors.groupingBy(w -> w.charAt(0), TreeMap::new,
Collectors.toList()));
Map<Character, List<String>> filteredInside = words.stream()
.collect(Collectors.groupingBy(w -> w.charAt(0), TreeMap::new,
Collectors.filtering(w -> w.length() > 3, Collectors.toList())));
System.out.println(filteredFirst);
System.out.println(filteredInside);
}
}Example explained
Line 1.filter(w -> w.length() > 3) drops ant, arc, bee and cow before the classifier runs, so the keys a and c are never created.
Line 2Collectors.filtering runs after the classifier has already picked the bucket, so every distinct first letter still gets an entry.
Line 3The empty lists come from toList()'s supplier: the bucket container was built for the key, then nothing was accumulated into it.
Line 4Choose the second form when a report must show zero rows for a category; Collectors.filtering requires Java 9 or later.
Partitioning nested inside grouping
The downstream is itself a collector, so a partition can live inside every group and always shows both sides.
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.stream.Collectors;
public class Main {
record Order(String customer, boolean paid) {}
public static void main(String[] args) {
List<Order> orders = List.of(
new Order("ada", true),
new Order("ada", false),
new Order("bo", false),
new Order("bo", true),
new Order("cy", true));
Map<String, Map<Boolean, Long>> counts = orders.stream().collect(
Collectors.groupingBy(Order::customer, TreeMap::new,
Collectors.partitioningBy(Order::paid, Collectors.counting())));
System.out.println(counts);
System.out.println(counts.get("cy").get(false));
}
}Example explained
Line 1partitioningBy(Order::paid, counting()) is the downstream of groupingBy, so each customer key maps to a two-entry map instead of a list.
Line 2cy has only a paid order, yet false=0 appears because partitioningBy builds both containers before any element arrives and finishes both.
Line 3counting() finishes as Long, so the inner value type is Long and counts.get("cy").get(false) is a real 0 rather than null.
Line 4TreeMap::new fixes the customer order; the inner partition map is not a HashMap and needs no factory.
Reshaping a group's value
collectingAndThen unwraps the Optional from maxBy, and mapping feeds joining so no intermediate list is built.
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.stream.Collectors;
public class Main {
record Book(String genre, String title, int pages) {}
public static void main(String[] args) {
List<Book> books = List.of(
new Book("scifi", "Dune", 412),
new Book("scifi", "Solaris", 204),
new Book("crime", "Gorky Park", 365));
Map<String, String> longest = books.stream().collect(
Collectors.groupingBy(Book::genre, TreeMap::new,
Collectors.collectingAndThen(
Collectors.maxBy(Comparator.comparingInt(Book::pages)),
best -> best.map(Book::title).orElse("none"))));
Map<String, String> titles = books.stream().collect(
Collectors.groupingBy(Book::genre, TreeMap::new,
Collectors.mapping(Book::title, Collectors.joining(" | "))));
System.out.println(longest);
System.out.println(titles);
}
}Example explained
Line 1maxBy finishes each bucket as Optional<Book>, which is why Map<String, String> would not compile without a further step.
Line 2collectingAndThen adds one function to the downstream's finisher, converting each group's Optional<Book> into a title String.
Line 3orElse("none") never fires here because groupingBy creates a key only when at least one element landed in it.
Line 4mapping(Book::title, joining(" | ")) turns each Book into a String on the way into the joining collector, so the group is never materialised as a list.
Important notes
Collectors.filtering and Collectors.flatMapping exist only from Java 9; on Java 8 the only option is filtering upstream, which also removes the empty groups.
Without a map factory, groupingBy returns a HashMap and toList() an unspecified List implementation, so treat iteration order and mutability of the result as undefined.
Common mistakes
Declaring the result of counting() as Map<String, Integer>: counting() finishes as Long, so the collect call fails with an incompatible-types error that points at the whole collector expression.
Grouping by a field that can be null: groupingBy calls the classifier and rejects a null key with a NullPointerException instead of creating a null bucket, so one bad record aborts the entire collect.
Using maxBy or minBy as the downstream and expecting Map<String, Employee>: the values are Optional<Employee>, and code that keeps calling get() on them either fails to compile or hides an empty case that cannot happen anyway.
Try it yourself
Change, predict, then run
Take List.of("apple", "avocado", "beet", "fig", "kiwi", "kale") and build a Map<Character, Long> of first letter to word count using groupingBy with counting(). Then partition the same list by length >= 5 and use joining(", ") as the downstream so each side is a single String.
Open the Java workspaceCheck your understanding
A list of six tasks contains no task with status DONE. You collect it once with groupingBy(Task::status, counting()) and once with partitioningBy(t -> t.status() == Status.DONE, counting()). What do the two results say about the DONE case?
- Both results contain a DONE entry mapped to 0, because both use counting().
- The grouping result maps DONE to 0, while the partition result omits the true key.
- The grouping result has no DONE key, while the partition result has false=6 and true=0.
- Both results skip the empty case, so each ends up with a single entry.
Show answer
groupingBy creates a bucket only when the classifier actually returns that key, so a status no element has never appears in the map. partitioningBy creates both containers before accumulating and finishes both, so the empty true side becomes counting()'s result for zero elements, 0. Option 0 is tempting because the downstream is identical, but a zero count needs an existing bucket, and grouping never allocates one for a key it has not seen.