Collectors, groupingBy and Downstream Collectors
The collector API in depth: multi-level groupingBy, downstream collectors, the toMap duplicate-key exception, the null-value trap, teeing and flatMapping, and writing a Collector by hand.
On this page
Collectors is where most real stream code ends up, and where the API has the sharpest edges. Two of
the failures on this page — the duplicate key and the null value — regularly reach production,
because both depend on data rather than on code.
Key Takeaways
groupingBytakes a downstream collector as a second argument. That is what makes it compose.toMapthrows on duplicate keys unless you supply a merge function, and throws on null values always.partitioningByalways returns bothtrueandfalsekeys;groupingByomits empty groups.counting,summingInt,mapping,flatMapping,filtering,reducingandteeingare all downstream collectors.- A
Collectoris five functions: supplier, accumulator, combiner, finisher, characteristics.
groupingBy, and the downstream argument
Map<String, List<Employee>> byDept = staff.stream()
.collect(Collectors.groupingBy(Employee::department));The single-argument form is groupingBy(classifier, toList()) with the downstream defaulted. Once
you see it that way, everything else is substitution:
// How many per department
Map<String, Long> headcount = staff.stream()
.collect(groupingBy(Employee::department, counting()));
// Total salary per department
Map<String, Integer> payroll = staff.stream()
.collect(groupingBy(Employee::department, summingInt(Employee::salary)));
// Just the names, not whole objects
Map<String, List<String>> names = staff.stream()
.collect(groupingBy(Employee::department, mapping(Employee::name, toList())));
// Highest earner per department
Map<String, Optional<Employee>> top = staff.stream()
.collect(groupingBy(Employee::department, maxBy(comparingInt(Employee::salary))));
// The same, unwrapped, using a finisher
Map<String, Employee> topUnwrapped = staff.stream()
.collect(groupingBy(Employee::department,
collectingAndThen(maxBy(comparingInt(Employee::salary)), Optional::orElseThrow)));
// Sorted keys instead of a HashMap, by supplying the map factory
TreeMap<String, Long> sorted = staff.stream()
.collect(groupingBy(Employee::department, TreeMap::new, counting()));Multi-level grouping is just a groupingBy used as its own downstream:
Map<String, Map<Boolean, List<Employee>>> byDeptThenSeniority = staff.stream()
.collect(groupingBy(Employee::department,
partitioningBy(e -> e.salary() > 100_000)));toMap, and its two failure modes
// Throws IllegalStateException: Duplicate key ... if two orders share a reference
Map<String, Order> byRef = orders.stream()
.collect(toMap(Order::reference, o -> o));
// Explicit resolution — pick one, or combine
Map<String, Order> keepLatest = orders.stream()
.collect(toMap(Order::reference, o -> o,
(a, b) -> a.placedAt().isAfter(b.placedAt()) ? a : b));
// Choose the map implementation too
Map<String, Order> ordered = orders.stream()
.collect(toMap(Order::reference, o -> o, (a, b) -> b, LinkedHashMap::new));The three-argument form should arguably be the default. The two-argument version is only safe when you know the key is unique — and "the reference is unique" is exactly the kind of assumption that holds in test data and fails in production.
// NullPointerException, even though HashMap accepts null values
Map<String, String> managers = staff.stream()
.collect(toMap(Employee::name, Employee::managerName)); // managerName may be nulltoMap is implemented with Map.merge, and merge interprets a null value as "remove this entry" —
so the collector rejects null outright rather than losing data. The workaround when nulls are
meaningful:
Map<String, String> managers = staff.stream().collect(
HashMap::new,
(map, e) -> map.put(e.name(), e.managerName()),
HashMap::putAll);groupingBy has the mirror-image restriction: a null classifier result throws. Filter or map
nulls to a sentinel before grouping.
The downstream collector catalogue
| Collector | Produces |
|---|---|
toList(), toSet(), toUnmodifiableList() | Collections |
counting() | Long |
summingInt, averagingDouble, summarizingLong | Numeric aggregates |
joining(", ", "[", "]") | A delimited String |
minBy, maxBy | Optional<T> |
mapping(fn, downstream) | Transform before collecting |
flatMapping(fn, downstream) | Flatten before collecting (Java 9) |
filtering(pred, downstream) | Filter within a group (Java 9) |
collectingAndThen(c, finisher) | Post-process the result |
teeing(c1, c2, merger) | Two collectors, one pass (Java 12) |
reducing(identity, op) | General fold |
filtering is worth knowing because it differs from a filter earlier in the pipeline:
// Departments with no senior staff disappear entirely.
staff.stream().filter(e -> e.salary() > 100_000)
.collect(groupingBy(Employee::department));
// Every department appears; some map to an empty list.
staff.stream()
.collect(groupingBy(Employee::department,
filtering(e -> e.salary() > 100_000, toList())));teeing computes two aggregates in a single traversal:
record Report(long count, int total) { }
Report report = orders.stream().collect(
teeing(counting(),
summingInt(Order::quantity),
(count, total) -> new Report(count, total)));Writing a Collector
You rarely need to, and being able to describe the five parts is a strong interview answer.
/** Keeps only the N largest elements, without sorting the whole stream. */
public static <T> Collector<T, ?, List<T>> topN(int n, Comparator<T> comparator) {
return Collector.of(
// 1. supplier — a fresh mutable container
() -> new PriorityQueue<>(comparator),
// 2. accumulator — fold one element into the container
(queue, item) -> {
queue.offer(item);
if (queue.size() > n) queue.poll(); // drop the smallest
},
// 3. combiner — merge two containers (parallel only)
(a, b) -> {
b.forEach(item -> {
a.offer(item);
if (a.size() > n) a.poll();
});
return a;
},
// 4. finisher — container to result
queue -> {
List<T> out = new ArrayList<>(queue);
out.sort(comparator.reversed());
return out;
});
// 5. characteristics — none here. UNORDERED and IDENTITY_FINISH
// are the two that matter; declaring them wrongly breaks
// parallel results in ways that are very hard to reproduce.
}
List<Order> biggest = orders.stream().collect(topN(10, comparingInt(Order::quantity)));The combiner is the part people get wrong. It is only used in parallel execution, so a broken
combiner produces correct sequential results and wrong parallel ones — a bug that appears only under
load. If you write a collector, test it with .parallel().
Collect versus reduce
// reduce: immutable folding. Each step produces a NEW value.
Integer total = orders.stream().map(Order::quantity).reduce(0, Integer::sum);
// collect: mutable folding. Each step mutates a container.
List<String> names = orders.stream().map(Order::customer).collect(toList());
// Using reduce for accumulation is O(n^2) and unsafe in parallel:
List<String> bad = orders.stream()
.reduce(new ArrayList<>(),
(list, o) -> { list.add(o.customer()); return list; }, // shared mutable state
(a, b) -> { a.addAll(b); return a; });The rule: reduce for immutable values, collect for building containers. reduce with a mutable
accumulator is the specific mistake the API design was trying to prevent — it works sequentially,
corrupts data in parallel, and reads as if it should be fine.
Which map do you get?
groupingBy and toMap return a HashMap unless you say otherwise, which means the iteration order
is unspecified and can change between JDK versions. That is fine for a lookup table and wrong for
anything you are about to serialise into an API response, where a stable order is part of the
contract.
Both collectors take a map factory for exactly this reason. groupingBy(classifier, TreeMap::new, downstream) gives sorted keys; LinkedHashMap::new preserves encounter order, which combined with a
sorted() earlier in the pipeline gives you a deterministic response. ConcurrentHashMap::new with
groupingByConcurrent is the parallel-friendly variant, and it is worth knowing that
groupingByConcurrent discards encounter order in exchange for not needing a merge step.
The equivalent question for the values: toList() returns a mutable ArrayList today, but that is
explicitly unspecified. If callers must not mutate the group, use toUnmodifiableList() — and if you
depend on mutability, say so with an explicit toCollection(ArrayList::new) rather than relying on
the default. Stream.toList() from Java 16 returns an unmodifiable list, which is a genuine
behavioural difference from collect(toList()) and a reasonable interview question in itself.
Reading a nested collector
Deeply nested collectors are where readability collapses. Two habits help. First, extract the
downstream into a named local when the expression exceeds one line — a Collector<Employee, ?, Map<Boolean, Long>> variable with a meaningful name documents the intent far better than a nested
parenthesis. Second, read them from the inside out: the innermost collector says what each group
becomes, and each enclosing layer says how the groups are keyed.
It is also worth knowing when to stop. A three-level groupingBy producing a map of maps of maps is
usually a sign the result wants to be a flat list of records with a proper key type, sorted or
grouped downstream. Streams reward one clear transformation per pipeline; a pipeline that needs a
diagram to explain is one that should have been a loop with a well-named accumulator.
Interview shortlist
Be ready to write a multi-level groupingBy on a whiteboard, explain why toMap threw, describe the
difference between partitioningBy and groupingBy, and name the five parts of a Collector. The
toMap question is the most common of the four, because almost everyone has been bitten by it.
If you get the chance to volunteer one detail, make it the combiner. Saying "I test any custom collector with a parallel stream, because the combiner is unused sequentially and a wrong one only shows up under load" signals production experience more strongly than any amount of API recall.
Frequently Asked Questions
Why does Collectors.toMap throw IllegalStateException?
What is the difference between groupingBy and partitioningBy?
Why is Collectors.toMap rejecting a null value with a NullPointerException?
Related tutorials
- Stream API Fundamentals: Lazy PipelinesHow a stream pipeline actually executes: why nothing runs until the terminal operation, what short-circuiting really means, stateful versus stateless operations, and why a stream is single-use.
- Parallel Streams and the Common ForkJoinPoolWhy every parallel stream in your JVM shares one pool, which sources split well, the N times Q rule for deciding, and why a blocking call inside a parallel stream can stall the whole application.
- Functional Interfaces: Function, Predicate, Supplier, ConsumerThe four core shapes and how to derive the other thirty-nine, why primitive specialisations exist, compose versus andThen, and the checked-exception problem with a clean workaround.
- Optional: Correct Use and Common AbuseWhat Optional was designed for and what it was not, the orElse versus orElseGet trap that evaluates the fallback every time, chaining with map and flatMap, and why Optional fields are a mistake.