Java Collectors and groupingBy
Java Collectors and groupingBy
Collectors is the factory class holding every pre-built recipe collect() can use, and groupingBy() is its most powerful single method — it takes a classification rule and splits a stream's elements into a Map, one bucket per distinct key, turning what used to be a manual "loop plus HashMap<K, List<V>>" pattern into a single line. Beyond grouping, Collectors also covers counting, joining, summarizing, partitioning, and combining collectors together through downstream collectors, which is what lets one collect() call produce a fully aggregated report instead of a flat list.
What Are Collectors and groupingBy()?
Collectors is a final class of static factory methods, each returning a Collector object ready to hand to collect(). groupingBy() comes in three overloaded forms. groupingBy(Function<T, K> classifier) groups elements into Map<K, List<T>>. groupingBy(Function<T, K> classifier, Collector<T, ?, D> downstream) applies a second collector to each group's elements instead of just collecting them into a List, producing Map<K, D>. groupingBy(Function<T, K> classifier, Supplier<M> mapFactory, Collector<T, ?, D> downstream) additionally lets the caller control which Map implementation actually gets built.
partitioningBy() is a specialized, always-binary version of grouping — it takes a Predicate instead of a classifier and always produces exactly Map<Boolean, List<T>> with both true and false keys present, even when one side ends up empty. groupingBy(), by contrast, only ever creates keys that actually occur in the data.
Why groupingBy() Was Introduced
Grouping elements by a shared property used to mean a loop and a Map populated by hand, checking whether each key had already been seen before creating its list.
// File: BeforeGroupingBy.java
import java.util.*;
public class BeforeGroupingBy {
record Order(String id, String category, double amount) {}
public static void main(String[] args) {
List<Order> orders = List.of(
new Order("ORD-1", "Electronics", 799.0),
new Order("ORD-2", "Books", 450.0),
new Order("ORD-3", "Electronics", 1499.0)
);
Map<String, List<Order>> byCategory = new HashMap<>();
for (Order order : orders) {
byCategory.computeIfAbsent(order.category(), k -> new ArrayList<>()).add(order);
}
System.out.println(byCategory.get("Electronics").size());
}
}Output:
2
groupingBy() folds the same logic into one call, with the classifier function as the only thing that would ever need to change between one grouping and the next.
// File: AfterGroupingBy.java
import java.util.*;
import java.util.stream.*;
public class AfterGroupingBy {
record Order(String id, String category, double amount) {}
public static void main(String[] args) {
List<Order> orders = List.of(
new Order("ORD-1", "Electronics", 799.0),
new Order("ORD-2", "Books", 450.0),
new Order("ORD-3", "Electronics", 1499.0)
);
Map<String, List<Order>> byCategory = orders.stream()
.collect(Collectors.groupingBy(Order::category));
System.out.println(byCategory.get("Electronics").size());
}
}Output:
2
Both versions find the same two Electronics orders. The stream version has no computeIfAbsent call or manually managed Map for a reader to verify.
Syntax
Each groupingBy() overload adds one more layer of control, and partitioningBy() follows the same shape for a binary condition.
// File: GroupingBySyntaxForms.java
import java.util.*;
import java.util.stream.*;
public class GroupingBySyntaxForms {
record Order(String id, String category, double amount) {}
public static void main(String[] args) {
List<Order> orders = List.of(
new Order("ORD-1", "Electronics", 799.0),
new Order("ORD-2", "Books", 450.0),
new Order("ORD-3", "Electronics", 1499.0),
new Order("ORD-4", "Books", 300.0)
);
// One-argument form - groups into Map<K, List<T>>
Map<String, List<Order>> basicGrouping = orders.stream()
.collect(Collectors.groupingBy(Order::category));
// Two-argument form - applies a downstream collector per group
Map<String, Long> countPerCategory = orders.stream()
.collect(Collectors.groupingBy(Order::category, Collectors.counting()));
// Three-argument form - controls the Map implementation itself
Map<String, Long> sortedCountPerCategory = orders.stream()
.collect(Collectors.groupingBy(Order::category, TreeMap::new, Collectors.counting()));
// partitioningBy - always exactly two keys, true and false
Map<Boolean, List<Order>> partitioned = orders.stream()
.collect(Collectors.partitioningBy(order -> order.amount() > 500));
System.out.println("Electronics count: " + basicGrouping.get("Electronics").size());
System.out.println("Electronics order count: " + countPerCategory.get("Electronics"));
System.out.println("Books order count: " + countPerCategory.get("Books"));
System.out.println("Sorted count per category: " + sortedCountPerCategory);
System.out.println("High value count: " + partitioned.get(true).size());
System.out.println("Low value count: " + partitioned.get(false).size());
}
}Output:
Electronics count: 2
Electronics order count: 2
Books order count: 2
Sorted count per category: {Books=2, Electronics=2}
High value count: 2
Low value count: 2
Common Use Cases
groupingBy() With counting()
Collectors.counting() as a downstream collector turns each group's element list directly into a count, without a separate .size() call afterward.
// File: GroupingByCountingExample.java
import java.util.*;
import java.util.stream.*;
public class GroupingByCountingExample {
record Order(String category) {}
public static void main(String[] args) {
List<Order> orders = List.of(
new Order("Electronics"), new Order("Books"),
new Order("Electronics"), new Order("Electronics")
);
Map<String, Long> countByCategory = orders.stream()
.collect(Collectors.groupingBy(Order::category, Collectors.counting()));
System.out.println("Electronics: " + countByCategory.get("Electronics"));
System.out.println("Books: " + countByCategory.get("Books"));
}
}Output:
Electronics: 3
Books: 1
groupingBy() With mapping()
Collectors.mapping() transforms each element within a group before collecting it, useful whenever the grouped result should hold just one field instead of the entire object.
// File: GroupingByMappingExample.java
import java.util.*;
import java.util.stream.*;
public class GroupingByMappingExample {
record Order(String id, String category) {}
public static void main(String[] args) {
List<Order> orders = List.of(
new Order("ORD-1", "Electronics"),
new Order("ORD-2", "Books"),
new Order("ORD-3", "Electronics")
);
Map<String, List<String>> idsByCategory = orders.stream()
.collect(Collectors.groupingBy(Order::category, Collectors.mapping(Order::id, Collectors.toList())));
System.out.println("Electronics order ids: " + idsByCategory.get("Electronics"));
}
}Output:
Electronics order ids: [ORD-1, ORD-3]
groupingBy() With summingDouble()
Collectors.summingDouble() totals a numeric field within each group, computing the aggregation in the same pass the grouping happens in.
// File: GroupingBySummingExample.java
import java.util.*;
import java.util.stream.*;
public class GroupingBySummingExample {
record Order(String category, double amount) {}
public static void main(String[] args) {
List<Order> orders = List.of(
new Order("Electronics", 799.0),
new Order("Books", 450.0),
new Order("Electronics", 1499.0)
);
Map<String, Double> totalByCategory = orders.stream()
.collect(Collectors.groupingBy(Order::category, Collectors.summingDouble(Order::amount)));
System.out.println("Electronics total: " + totalByCategory.get("Electronics"));
System.out.println("Books total: " + totalByCategory.get("Books"));
}
}Output:
Electronics total: 2298.0
Books total: 450.0
partitioningBy() With a Downstream Collector
partitioningBy() accepts a downstream collector the same way groupingBy() does, applying it to each of the exactly two resulting groups.
// File: PartitioningByDownstreamExample.java
import java.util.*;
import java.util.stream.*;
public class PartitioningByDownstreamExample {
record Order(double amount) {}
public static void main(String[] args) {
List<Order> orders = List.of(
new Order(799.0), new Order(450.0), new Order(1499.0), new Order(300.0)
);
Map<Boolean, Long> countByHighValue = orders.stream()
.collect(Collectors.partitioningBy(order -> order.amount() > 500, Collectors.counting()));
System.out.println("High value count: " + countByHighValue.get(true));
System.out.println("Low value count: " + countByHighValue.get(false));
}
}Output:
High value count: 2
Low value count: 2
Nested (Multi-Level) groupingBy()
Passing another groupingBy() call as the downstream collector groups within each already-formed group, producing a Map of Maps.
// File: NestedGroupingByExample.java
import java.util.*;
import java.util.stream.*;
public class NestedGroupingByExample {
record Order(String category, String status) {}
public static void main(String[] args) {
List<Order> orders = List.of(
new Order("Electronics", "DELIVERED"),
new Order("Electronics", "CANCELLED"),
new Order("Electronics", "DELIVERED"),
new Order("Books", "DELIVERED")
);
Map<String, Map<String, Long>> byCategoryThenStatus = orders.stream()
.collect(Collectors.groupingBy(Order::category,
Collectors.groupingBy(Order::status, Collectors.counting())));
System.out.println("Electronics delivered: " + byCategoryThenStatus.get("Electronics").get("DELIVERED"));
System.out.println("Electronics cancelled: " + byCategoryThenStatus.get("Electronics").get("CANCELLED"));
}
}Output:
Electronics delivered: 2
Electronics cancelled: 1
collectingAndThen() to Post-Process a Result
Collectors.collectingAndThen() takes a finished collector and applies one more transformation to its result, most commonly used to make a collected structure immutable.
// File: CollectingAndThenExample.java
import java.util.*;
import java.util.stream.*;
public class CollectingAndThenExample {
public static void main(String[] args) {
List<String> categories = List.of("Electronics", "Books", "Electronics");
List<String> immutableUniqueCategories = categories.stream()
.distinct()
.collect(Collectors.collectingAndThen(Collectors.toList(), Collections::unmodifiableList));
System.out.println(immutableUniqueCategories);
try {
immutableUniqueCategories.add("Toys");
} catch (UnsupportedOperationException e) {
System.out.println("collectingAndThen wrapped the result as unmodifiable");
}
}
}Output:
[Electronics, Books]
collectingAndThen wrapped the result as unmodifiable
Other Notable Collectors
A handful of additional Collectors methods come up often enough to be worth knowing by name, even without a dedicated example for each.
| Method | What It Does |
|---|---|
joining(delimiter, prefix, suffix) | Joins stream elements into one formatted String |
minBy(comparator) / maxBy(comparator) | Finds the smallest or largest element, returning Optional<T> |
averagingInt() / averagingDouble() | Computes a numeric average directly |
summarizingDouble() / summarizingInt() | Computes count, sum, min, max, and average together in one collector |
reducing(identity, accumulator) | Expresses a reduce()-style combination as a Collector |
teeing(downstream1, downstream2, merger) | Runs two collectors over the same stream in one pass and merges their results |
Basic Example
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("apple", "banana", "kiwi", "fig", "grape");
Map<Integer, Long> countByLength = words.stream()
.collect(Collectors.groupingBy(String::length, TreeMap::new, Collectors.counting()));
System.out.println(countByLength);
}
}Output
{3=1, 4=1, 5=2, 6=1}
How it works:
- ›
Collectors.groupingBy(String::length, TreeMap::new, Collectors.counting())classifies each word by its length, usingString::lengthas the classifier. - ›
TreeMap::newtellsgroupingBy()to build aTreeMapinstead of the defaultHashMap, so the printed keys come out in sorted order. - ›
Collectors.counting()is the downstream collector — instead of collecting each group into aList<String>, it reduces each group straight down to how many words share that length. - ›
"fig"(length 3) and"kiwi"(length 4) each appear once,"apple"and"grape"(length 5) appear together, and"banana"(length 6) appears once — matching the printed map exactly.
Combining Collectors With Other Features
groupingBy()'s classifier is a Function, and partitioningBy()'s condition is a Predicate — both tie directly back to the functional interfaces covered earlier in this series. Downstream collectors compose the way Function.andThen() chains transformations, just applied to an entire group of elements instead of a single value. groupingBy(classifier) with no downstream collector is exactly equivalent to groupingBy(classifier, Collectors.toList()), since toList() is the implicit default. collectingAndThen() is the collector-level equivalent of Optional.map() — it takes an already-finished result and transforms it once more before handing it back.
Best Practices
Reach for a downstream collector — counting(), summingDouble(), mapping(), averagingDouble() — instead of grouping into a List and then running a second pass over each group afterward. The downstream collector performs the aggregation in the same pass the grouping already happens in.
Use partitioningBy() instead of groupingBy() whenever the classification is genuinely binary. It guarantees both true and false keys exist in the result, even when one side is empty, which groupingBy() does not guarantee for keys that never actually occur.
Supply an explicit map factory, like TreeMap::new, to groupingBy()'s three-argument form whenever the grouped result needs to be iterated in a predictable order, since the default is an unordered HashMap.
Prefer Collectors.summarizingDouble() or summarizingInt() over separately grouping-and-counting, grouping-and-summing, and grouping-and-averaging in three different collect() calls whenever count, sum, and average are all needed together.
Common Mistakes
Expecting a predictable iteration order from groupingBy()'s default result is a mistake that only shows up once the keys happen not to already look sorted by coincidence.
// File: GroupingByUnorderedMistake.java
import java.util.*;
import java.util.stream.*;
public class GroupingByUnorderedMistake {
public static void main(String[] args) {
List<String> categories = List.of("Zebra", "Apple", "Mango");
// The default Map returned by groupingBy() is a HashMap - no
// guaranteed iteration order, even though these three category
// names would happen to print alphabetically by coincidence
Map<String, Long> defaultMapGrouping = categories.stream()
.collect(Collectors.groupingBy(name -> name, Collectors.counting()));
// Supplying TreeMap::new guarantees sorted key order explicitly
Map<String, Long> sortedMapGrouping = categories.stream()
.collect(Collectors.groupingBy(name -> name, TreeMap::new, Collectors.counting()));
System.out.println("Sorted grouping: " + sortedMapGrouping);
System.out.println("Default grouping has " + defaultMapGrouping.size() + " entries, order not guaranteed");
}
}Output:
Sorted grouping: {Apple=1, Mango=1, Zebra=1}
Default grouping has 3 entries, order not guaranteed
Using groupingBy() for a condition that is really binary can silently leave a key out of the result entirely, unlike partitioningBy(), which always produces both keys regardless of the data.
// File: GroupingByMissingKeyMistake.java
import java.util.*;
import java.util.stream.*;
public class GroupingByMissingKeyMistake {
record Order(double amount) {}
public static void main(String[] args) {
List<Order> allHighValueOrders = List.of(new Order(800.0), new Order(900.0));
// groupingBy() only creates keys that actually occur - since every
// order here is high value, the "false" key never appears at all
Map<Boolean, List<Order>> viaGroupingBy = allHighValueOrders.stream()
.collect(Collectors.groupingBy(order -> order.amount() > 500));
System.out.println("groupingBy contains false key: " + viaGroupingBy.containsKey(false));
// partitioningBy() always produces both true and false keys,
// even when one side ends up with zero matching elements
Map<Boolean, List<Order>> viaPartitioningBy = allHighValueOrders.stream()
.collect(Collectors.partitioningBy(order -> order.amount() > 500));
System.out.println("partitioningBy contains false key: " + viaPartitioningBy.containsKey(false));
System.out.println("partitioningBy false list: " + viaPartitioningBy.get(false));
}
}Output:
groupingBy contains false key: false
partitioningBy contains false key: true
partitioningBy false list: []
Calling .get() on a groupingBy() result for a key that might not exist, and treating a null return as if it were an empty list, is the natural consequence of the mistake above — the fix is checking containsKey() first, using getOrDefault(key, List.of()), or switching to partitioningBy() when the classification is binary in the first place.
Interview Questions
Q1. What does Collectors.groupingBy() do, and what are its three overloaded forms?
groupingBy() splits a stream's elements into a Map keyed by the result of a classifier function. The single-argument form groups into Map<K, List<T>>. The two-argument form applies an additional downstream collector to each group instead of collecting into a List. The three-argument form additionally lets the caller supply the specific Map implementation to use, such as TreeMap for sorted keys. Interviewers commonly ask for all three to see whether a candidate has moved past the most basic form.
Q2. What is a downstream collector, and why does groupingBy() accept one?
A downstream collector is a second Collector applied to the elements within each group, instead of the default behavior of just gathering them into a List. groupingBy() accepts one so that a group's elements can be counted, summed, transformed, or further sub-grouped in the same pass the grouping happens in, rather than requiring a second pass over each group afterward.
Q3. What is the difference between groupingBy() and partitioningBy()?
groupingBy() accepts any classifier function and produces one key per distinct value that function returns, only including keys that actually occur in the data. partitioningBy() accepts specifically a Predicate and always produces exactly two keys, true and false, even if every element falls into just one of them. partitioningBy() is the more precise choice whenever the classification is genuinely binary, since it guarantees both branches are present in the result.
Q4. What Map implementation does groupingBy() return by default, and how would you control it?
By default, groupingBy() returns a HashMap, which offers no guarantee about iteration order. The three-argument overload accepts a Supplier<M> map factory, and passing TreeMap::new produces a result with keys in their natural sorted order instead, while LinkedHashMap::new would preserve the order keys were first encountered.
Q5. How would you compute both a count and a sum for each group in a single pass?
Collectors.summarizingDouble() or summarizingInt(), used as the downstream collector, computes count, sum, minimum, maximum, and average together in one pass, returning a DoubleSummaryStatistics or IntSummaryStatistics object with all of them accessible as methods. This avoids running separate groupingBy() calls for each individual metric.
Q6. What does Collectors.collectingAndThen() do, and when would you use it?
collectingAndThen() wraps an existing collector and applies one more transformation to its finished result before returning it — most commonly used to wrap a mutable collected result, like a List built by toList(), with Collections::unmodifiableList to make it immutable. It is the way to add a final post-processing step to a collector without writing an entirely custom one.
FAQs
Does groupingBy() work with a Comparator instead of a classifier function?
No. groupingBy() requires a Function that computes a key for each element, not a Comparator. Sorting the result of a groupingBy() call is a separate step, typically done by sorting the resulting Map's entries afterward or by choosing a TreeMap as the map factory if the keys themselves should be sorted.
Can groupingBy() be nested to group by more than one key?
Yes, by passing another groupingBy() call as the downstream collector, exactly as the nested category-then-status example in this article shows. This produces a Map of Maps, one level per grouping key.
What happens if the classifier function passed to groupingBy() returns null for some elements?
groupingBy() throws NullPointerException, since HashMap (and most Map implementations used as the default) does not accept null as a grouping key in this context. Any classifier that might legitimately return null needs to map that case to a sentinel value first, such as an explicit "UNKNOWN" string.
Is Collectors.toMap() the same as Collectors.groupingBy() with one element per key?
Conceptually similar but different in behavior on conflict. toMap() throws IllegalStateException if two elements produce the same key, unless a merge function is supplied. groupingBy() never throws for repeated keys — it simply collects every matching element into that key's group, which is why groupingBy() is the right choice whenever more than one element can share a key.
What is the difference between Collectors.summingDouble() and Collectors.averagingDouble()?
summingDouble() computes the total of a numeric field extracted from each element, while averagingDouble() computes the mean. Both accept a ToDoubleFunction to extract the numeric value from each element, and both work identically well as either a standalone collector or a downstream collector inside groupingBy().
Does partitioningBy() support a downstream collector the same way groupingBy() does?
Yes. partitioningBy(predicate, downstream) applies the downstream collector to each of the two partitions, exactly the way groupingBy()'s two-argument form does for each of its groups — the PartitioningByDownstreamExample earlier in this article demonstrates exactly this.
What is Collectors.teeing(), and when was it introduced?
Collectors.teeing(), introduced in Java 12, runs two separate collectors over the same stream in a single pass and combines their two results with a supplied BiFunction. It is useful whenever two different aggregations — a count and a sum, for example — are both needed from the same stream without iterating it twice, though summarizingDouble() already covers the specific count-sum-average combination more directly.
Summary
groupingBy() turns a flat stream into a structured Map, and downstream collectors are what let that structure carry an aggregation — a count, a sum, a nested sub-grouping — instead of just a raw list per key. partitioningBy() handles the specific, common case of a binary split more precisely, guaranteeing both outcomes exist in the result even when one side is empty, exactly the distinction the seller dashboard example and the missing-key mistake both illustrate.
The habit worth carrying forward is reaching for the right downstream collector — counting(), summingDouble(), summarizingDouble(), mapping() — instead of grouping into lists and then running a second pass over each group by hand. Once grouping, partitioning, and downstream collectors feel natural together, most real reporting logic over a stream of data reduces to a single, well-chosen collect() call.
What to Read Next
Learn how to run a Stream across multiple CPU cores.
Practice Programs
Put this topic into practice — each program has full code, output and a step-by-step explanation you can run online.
Groups a list of words into buckets keyed by word length using Collectors.groupingBy.
Splits exam marks into pass and fail lists in one pass with Collectors.partitioningBy.
Builds a map from a list with Collectors.toMap, choosing the key, value, merge rule and map type.
Counts how many times each element appears in an array.
Splits an array's elements into separate lists of even and odd numbers.
Finds the element that appears most often in an array.
Splits an array's elements into separate lists of positive and negative numbers.
Converts a sentence to title case, upper-casing each word's first letter and tidying extra spaces.
Groups employees by department and computes each department's average salary with groupingBy and averagingInt.
Combines a list of strings into one with Collectors.joining, using a separator, prefix and suffix.