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.
1// File: BeforeGroupingBy.java
2import java.util.*;
3
4public class BeforeGroupingBy {
5 record Order(String id, String category, double amount) {}
6
7 public static void main(String[] args) {
8 List<Order> orders = List.of(
9 new Order("ORD-1", "Electronics", 799.0),
10 new Order("ORD-2", "Books", 450.0),
11 new Order("ORD-3", "Electronics", 1499.0)
12 );
13
14 Map<String, List<Order>> byCategory = new HashMap<>();
15 for (Order order : orders) {
16 byCategory.computeIfAbsent(order.category(), k -> new ArrayList<>()).add(order);
17 }
18
19 System.out.println(byCategory.get("Electronics").size());
20 }
21}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.
1// File: AfterGroupingBy.java
2import java.util.*;
3import java.util.stream.*;
4
5public class AfterGroupingBy {
6 record Order(String id, String category, double amount) {}
7
8 public static void main(String[] args) {
9 List<Order> orders = List.of(
10 new Order("ORD-1", "Electronics", 799.0),
11 new Order("ORD-2", "Books", 450.0),
12 new Order("ORD-3", "Electronics", 1499.0)
13 );
14
15 Map<String, List<Order>> byCategory = orders.stream()
16 .collect(Collectors.groupingBy(Order::category));
17
18 System.out.println(byCategory.get("Electronics").size());
19 }
20}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.
1// File: GroupingBySyntaxForms.java
2import java.util.*;
3import java.util.stream.*;
4
5public class GroupingBySyntaxForms {
6 record Order(String id, String category, double amount) {}
7
8 public static void main(String[] args) {
9 List<Order> orders = List.of(
10 new Order("ORD-1", "Electronics", 799.0),
11 new Order("ORD-2", "Books", 450.0),
12 new Order("ORD-3", "Electronics", 1499.0),
13 new Order("ORD-4", "Books", 300.0)
14 );
15
16 // One-argument form - groups into Map<K, List<T>>
17 Map<String, List<Order>> basicGrouping = orders.stream()
18 .collect(Collectors.groupingBy(Order::category));
19
20 // Two-argument form - applies a downstream collector per group
21 Map<String, Long> countPerCategory = orders.stream()
22 .collect(Collectors.groupingBy(Order::category, Collectors.counting()));
23
24 // Three-argument form - controls the Map implementation itself
25 Map<String, Long> sortedCountPerCategory = orders.stream()
26 .collect(Collectors.groupingBy(Order::category, TreeMap::new, Collectors.counting()));
27
28 // partitioningBy - always exactly two keys, true and false
29 Map<Boolean, List<Order>> partitioned = orders.stream()
30 .collect(Collectors.partitioningBy(order -> order.amount() > 500));
31
32 System.out.println("Electronics count: " + basicGrouping.get("Electronics").size());
33 System.out.println("Electronics order count: " + countPerCategory.get("Electronics"));
34 System.out.println("Books order count: " + countPerCategory.get("Books"));
35 System.out.println("Sorted count per category: " + sortedCountPerCategory);
36 System.out.println("High value count: " + partitioned.get(true).size());
37 System.out.println("Low value count: " + partitioned.get(false).size());
38 }
39}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.
1// File: GroupingByCountingExample.java
2import java.util.*;
3import java.util.stream.*;
4
5public class GroupingByCountingExample {
6 record Order(String category) {}
7
8 public static void main(String[] args) {
9 List<Order> orders = List.of(
10 new Order("Electronics"), new Order("Books"),
11 new Order("Electronics"), new Order("Electronics")
12 );
13
14 Map<String, Long> countByCategory = orders.stream()
15 .collect(Collectors.groupingBy(Order::category, Collectors.counting()));
16
17 System.out.println("Electronics: " + countByCategory.get("Electronics"));
18 System.out.println("Books: " + countByCategory.get("Books"));
19 }
20}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.
1// File: GroupingByMappingExample.java
2import java.util.*;
3import java.util.stream.*;
4
5public class GroupingByMappingExample {
6 record Order(String id, String category) {}
7
8 public static void main(String[] args) {
9 List<Order> orders = List.of(
10 new Order("ORD-1", "Electronics"),
11 new Order("ORD-2", "Books"),
12 new Order("ORD-3", "Electronics")
13 );
14
15 Map<String, List<String>> idsByCategory = orders.stream()
16 .collect(Collectors.groupingBy(Order::category, Collectors.mapping(Order::id, Collectors.toList())));
17
18 System.out.println("Electronics order ids: " + idsByCategory.get("Electronics"));
19 }
20}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.
1// File: GroupingBySummingExample.java
2import java.util.*;
3import java.util.stream.*;
4
5public class GroupingBySummingExample {
6 record Order(String category, double amount) {}
7
8 public static void main(String[] args) {
9 List<Order> orders = List.of(
10 new Order("Electronics", 799.0),
11 new Order("Books", 450.0),
12 new Order("Electronics", 1499.0)
13 );
14
15 Map<String, Double> totalByCategory = orders.stream()
16 .collect(Collectors.groupingBy(Order::category, Collectors.summingDouble(Order::amount)));
17
18 System.out.println("Electronics total: " + totalByCategory.get("Electronics"));
19 System.out.println("Books total: " + totalByCategory.get("Books"));
20 }
21}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.
1// File: PartitioningByDownstreamExample.java
2import java.util.*;
3import java.util.stream.*;
4
5public class PartitioningByDownstreamExample {
6 record Order(double amount) {}
7
8 public static void main(String[] args) {
9 List<Order> orders = List.of(
10 new Order(799.0), new Order(450.0), new Order(1499.0), new Order(300.0)
11 );
12
13 Map<Boolean, Long> countByHighValue = orders.stream()
14 .collect(Collectors.partitioningBy(order -> order.amount() > 500, Collectors.counting()));
15
16 System.out.println("High value count: " + countByHighValue.get(true));
17 System.out.println("Low value count: " + countByHighValue.get(false));
18 }
19}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.
1// File: NestedGroupingByExample.java
2import java.util.*;
3import java.util.stream.*;
4
5public class NestedGroupingByExample {
6 record Order(String category, String status) {}
7
8 public static void main(String[] args) {
9 List<Order> orders = List.of(
10 new Order("Electronics", "DELIVERED"),
11 new Order("Electronics", "CANCELLED"),
12 new Order("Electronics", "DELIVERED"),
13 new Order("Books", "DELIVERED")
14 );
15
16 Map<String, Map<String, Long>> byCategoryThenStatus = orders.stream()
17 .collect(Collectors.groupingBy(Order::category,
18 Collectors.groupingBy(Order::status, Collectors.counting())));
19
20 System.out.println("Electronics delivered: " + byCategoryThenStatus.get("Electronics").get("DELIVERED"));
21 System.out.println("Electronics cancelled: " + byCategoryThenStatus.get("Electronics").get("CANCELLED"));
22 }
23}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.
1// File: CollectingAndThenExample.java
2import java.util.*;
3import java.util.stream.*;
4
5public class CollectingAndThenExample {
6 public static void main(String[] args) {
7 List<String> categories = List.of("Electronics", "Books", "Electronics");
8
9 List<String> immutableUniqueCategories = categories.stream()
10 .distinct()
11 .collect(Collectors.collectingAndThen(Collectors.toList(), Collections::unmodifiableList));
12
13 System.out.println(immutableUniqueCategories);
14
15 try {
16 immutableUniqueCategories.add("Toys");
17 } catch (UnsupportedOperationException e) {
18 System.out.println("collectingAndThen wrapped the result as unmodifiable");
19 }
20 }
21}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 |
Real-World Example
A seller dashboard for an online marketplace needs a report showing, per product category, the order count, total revenue, and average order value, along with a separate breakdown of delivered versus cancelled orders. Collectors.summarizingDouble() computes the count, sum, and average together in a single pass per group, and partitioningBy() handles the delivered/cancelled split cleanly since it is a genuinely binary condition.
1// File: Order.java
2
3public record Order(String id, String category, double amount, String status) {}1// File: OrderAnalyticsReport.java
2import java.util.*;
3import java.util.stream.*;
4
5public class OrderAnalyticsReport {
6
7 public Map<String, DoubleSummaryStatistics> buildCategorySummary(List<Order> orders) {
8 return orders.stream()
9 .collect(Collectors.groupingBy(Order::category, Collectors.summarizingDouble(Order::amount)));
10 }
11
12 public Map<Boolean, Long> buildDeliveryBreakdown(List<Order> orders) {
13 return orders.stream()
14 .collect(Collectors.partitioningBy(order -> order.status().equals("DELIVERED"), Collectors.counting()));
15 }
16}1// File: OrderAnalyticsDemo.java
2import java.util.*;
3
4public class OrderAnalyticsDemo {
5 public static void main(String[] args) {
6 List<Order> orders = List.of(
7 new Order("ORD-1", "Electronics", 799.0, "DELIVERED"),
8 new Order("ORD-2", "Books", 450.0, "DELIVERED"),
9 new Order("ORD-3", "Electronics", 1499.0, "CANCELLED"),
10 new Order("ORD-4", "Electronics", 299.0, "DELIVERED"),
11 new Order("ORD-5", "Books", 300.0, "CANCELLED")
12 );
13
14 OrderAnalyticsReport report = new OrderAnalyticsReport();
15
16 Map<String, DoubleSummaryStatistics> categorySummary = report.buildCategorySummary(orders);
17 DoubleSummaryStatistics electronicsSummary = categorySummary.get("Electronics");
18
19 System.out.println("Electronics order count: " + electronicsSummary.getCount());
20 System.out.printf("Electronics total revenue: %.2f%n", electronicsSummary.getSum());
21 System.out.printf("Electronics average order value: %.2f%n", electronicsSummary.getAverage());
22
23 Map<Boolean, Long> deliveryBreakdown = report.buildDeliveryBreakdown(orders);
24 System.out.println("Delivered orders: " + deliveryBreakdown.get(true));
25 System.out.println("Cancelled orders: " + deliveryBreakdown.get(false));
26 }
27}Output:
Electronics order count: 3
Electronics total revenue: 2597.00
Electronics average order value: 865.67
Delivered orders: 3
Cancelled orders: 2
During code reviews, seniors commonly flag a report method that runs three separate stream passes — one to count, one to sum, one to average — over the same grouped data, when a single Collectors.summarizingDouble() downstream collector computes all three, plus minimum and maximum, in one pass per group. That difference matters once the order list is large enough that repeated full scans actually show up in response time.
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.
1// File: GroupingByUnorderedMistake.java
2import java.util.*;
3import java.util.stream.*;
4
5public class GroupingByUnorderedMistake {
6 public static void main(String[] args) {
7 List<String> categories = List.of("Zebra", "Apple", "Mango");
8
9 // The default Map returned by groupingBy() is a HashMap - no
10 // guaranteed iteration order, even though these three category
11 // names would happen to print alphabetically by coincidence
12 Map<String, Long> defaultMapGrouping = categories.stream()
13 .collect(Collectors.groupingBy(name -> name, Collectors.counting()));
14
15 // Supplying TreeMap::new guarantees sorted key order explicitly
16 Map<String, Long> sortedMapGrouping = categories.stream()
17 .collect(Collectors.groupingBy(name -> name, TreeMap::new, Collectors.counting()));
18
19 System.out.println("Sorted grouping: " + sortedMapGrouping);
20 System.out.println("Default grouping has " + defaultMapGrouping.size() + " entries, order not guaranteed");
21 }
22}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.
1// File: GroupingByMissingKeyMistake.java
2import java.util.*;
3import java.util.stream.*;
4
5public class GroupingByMissingKeyMistake {
6 record Order(double amount) {}
7
8 public static void main(String[] args) {
9 List<Order> allHighValueOrders = List.of(new Order(800.0), new Order(900.0));
10
11 // groupingBy() only creates keys that actually occur - since every
12 // order here is high value, the "false" key never appears at all
13 Map<Boolean, List<Order>> viaGroupingBy = allHighValueOrders.stream()
14 .collect(Collectors.groupingBy(order -> order.amount() > 500));
15
16 System.out.println("groupingBy contains false key: " + viaGroupingBy.containsKey(false));
17
18 // partitioningBy() always produces both true and false keys,
19 // even when one side ends up with zero matching elements
20 Map<Boolean, List<Order>> viaPartitioningBy = allHighValueOrders.stream()
21 .collect(Collectors.partitioningBy(order -> order.amount() > 500));
22
23 System.out.println("partitioningBy contains false key: " + viaPartitioningBy.containsKey(false));
24 System.out.println("partitioningBy false list: " + viaPartitioningBy.get(false));
25 }
26}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.