Skip to content
JavaAgentic

Type at least two characters. Try “RAG”, “pgvector” or “tool calling”.

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.

Intermediate7 min readUpdated
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

  • groupingBy takes a downstream collector as a second argument. That is what makes it compose.
  • toMap throws on duplicate keys unless you supply a merge function, and throws on null values always.
  • partitioningBy always returns both true and false keys; groupingBy omits empty groups.
  • counting, summingInt, mapping, flatMapping, filtering, reducing and teeing are all downstream collectors.
  • A Collector is five functions: supplier, accumulator, combiner, finisher, characteristics.

groupingBy, and the downstream argument

one level
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:

downstream collectors
// 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:

two levels
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

the duplicate key
// 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.

the null value
// NullPointerException, even though HashMap accepts null values
Map<String, String> managers = staff.stream()
        .collect(toMap(Employee::name, Employee::managerName));   // managerName may be null

toMap 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:

a map that tolerates nulls
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

CollectorProduces
toList(), toSet(), toUnmodifiableList()Collections
counting()Long
summingInt, averagingDouble, summarizingLongNumeric aggregates
joining(", ", "[", "]")A delimited String
minBy, maxByOptional<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:

filter before vs filtering within
// 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:

two answers, one pass
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.

TopNCollector.java
/** 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

the distinction interviewers probe
// 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?
Because two elements produced the same key and the two-argument form has no rule for resolving it, so rather than silently dropping data it throws "Duplicate key". Supply a merge function as a third argument — (a, b) -> a to keep the first, (a, b) -> b to keep the last, or something that actually combines them. This is one of the most common stream bugs to reach production, because it only fires on data that happens to contain a duplicate.
What is the difference between groupingBy and partitioningBy?
partitioningBy takes a Predicate and always returns a map with exactly two keys, true and false, both present even when one side is empty. groupingBy takes a general classifier function and returns one key per distinct value produced, with no key at all for groups that have no members. If your classifier is boolean, partitioningBy is faster and gives you both keys guaranteed.
Why is Collectors.toMap rejecting a null value with a NullPointerException?
toMap uses Map.merge internally, and merge treats a null value as a request to remove the entry, so the implementation forbids it outright. groupingBy has the same restriction on the classifier result — a null key throws. If your data has nulls, filter them first or map them to a sentinel value before collecting.

Related tutorials