Back to Blog
Java

Java Collectors groupingBy: Usage and Examples

java collectors groupingby: Learn how to use Java Collectors.groupingBy to group stream elements into maps, handle null keys, apply downstream collectors, and choose b...

Stream APICollectorsJava MapFunctional ProgrammingData Grouping
Java code snippet showing groupingBy collector used to group a stream of objects into a map

When you need to partition a stream of objects into groups based on a classification function, Collectors.groupingBy is the standard tool in the Java Stream API. The java collectors groupingby pattern produces a Map<K, List<T>> where each key is a group and each value is the list of elements that map to that key. This article explains how to use groupingBy effectively, including downstream collectors, null handling, and performance tradeoffs.

How groupingBy Works Under the Hood

The groupingBy collector uses a Map to accumulate results. For each element in the stream, it applies the classifier function to obtain a key, then stores the element in the list associated with that key. The default implementation uses HashMap for the map and ArrayList for the value lists. This means the resulting map has no guaranteed iteration order unless you supply a specific map supplier or use a downstream collector that preserves order.

The signature of the simplest overload is:

static <T, K> Collector<T, ?, Map<K, List<T>>> groupingBy(Function<? super T, ? extends K> classifier)

This returns a collector that can be passed to Stream.collect(). The classifier function maps each element to a key. For example, grouping a list of strings by their length:

List<String> words = List.of("apple", "banana", "cherry", "date"); Map<Integer, List<String>> byLength = words.stream() .collect(Collectors.groupingBy(String::length));

The result is {4=[date], 5=[apple, cherry], 6=[banana]}. Note that the order of keys depends on the HashMap iteration order, which is not deterministic.

Basic Grouping: Classifying Elements

The classifier function can be any Function that extracts a key from an element. A common use case is grouping domain objects by an attribute. Consider a Person class with name and city fields:

record Person(String name, String city) {} List<Person> people = List.of( new Person("Alice", "London"), new Person("Bob", "Paris"), new Person("Carol", "London") ); Map<String, List<Person>> byCity = people.stream() .collect(Collectors.groupingBy(Person::city));

This groups all people from the same city into a list. The key type is String, and the value type is List<Person>. If you need a different collection type, such as a Set, you can use a downstream collector.

Grouping with Downstream Collectors

The two-argument and three-argument overloads of groupingBy accept a downstream collector. This allows you to transform the grouped values instead of storing them as a raw list. The second overload is:

static <T, K, A, D> Collector<T, ?, Map<K, D>> groupingBy(Function<? super T, ? extends K> classifier, Collector<? super T, A, D> downstream)

For example, to count the number of people in each city, you can use Collectors.counting() as the downstream collector:

Map<String, Long> countByCity = people.stream() .collect(Collectors.groupingBy(Person::city, Collectors.counting()));

The result is {Paris=1, London=2}. Another common downstream is Collectors.mapping() to extract a specific field:

Map<String, List<String>> namesByCity = people.stream() .collect(Collectors.groupingBy(Person::city, Collectors.mapping(Person::name, Collectors.toList())));

This produces a map from city to a list of names, rather to a list of Person objects. You can chain downstream collectors to perform more complex reductions, such as summing a numeric field or joining strings.

The three-argument overload also lets you specify the map supplier:

Map<String, List<Person>> byCitySorted = people.stream() .collect(Collectors.groupingBy(Person::city, TreeMap::new, Collectors.toList()));

This uses a TreeMap, which sorts the keys in natural order. If you need a concurrent map, you can supply ConcurrentHashMap::new.

Handling Null Keys and Values

The default groupingBy implementation does not allow null keys. If the classifier returns null for any element, a NullPointerException is thrown at collection time. This is a common source of errors when grouping by a field that may be null. For example:

List<Person> peopleWithNullCity = List.of( new Person("Alice", "London"), new Person("Bob", null) ); peopleWithNullCity.stream() .collect(Collectors.groupingBy(Person::city)); // throws NullPointerException

To handle null keys, you can filter out nulls before grouping, or use a classifier that substitutes a default key:

Map<String, List<Person>> byCityWithDefault = peopleWithNullCity.stream() .collect(Collectors.groupingBy(p -> p.city() == null ? "Unknown" : p.city()));

Alternatively, use Collectors.toMap() with a merge function, but that has its own limitations. Null values in the stream are allowed; they are simply placed in the list. The key itself must be non-null unless you provide a custom map implementation that permits null keys, but the default HashMap does not.

Performance and Memory Considerations

groupingBy creates a map and a list for each group. The memory footprint depends on the number of distinct keys and the size of the lists. If the classifier produces a large number of keys, the map grows accordingly. The time complexity is O(n) for the stream traversal, assuming the map operations are constant time. However, the downstream collector can add overhead. For example, using Collectors.counting() is efficient because it uses a mutable Long accumulator, while Collectors.mapping() creates intermediate collections.

If you need to group a very large stream and the order of keys is not important, you can use the concurrent variant groupingByConcurrent(). This uses a ConcurrentHashMap and can improve parallelism when the stream is processed in parallel. The tradeoff is that the result map is not ordered and the downstream collector must be thread-safe.

Another performance consideration is the choice of map supplier. The default HashMap has a load factor of 0.75, which can cause rehashing if the number of groups is large. If you know the approximate number of groups, you can provide a map with a higher initial capacity to reduce rehash operations. However, this is rarely a bottleneck unless you are grouping millions of elements.

Choosing Between groupingBy and toMap

Both groupingBy and toMap produce a Map, but they serve different purposes. groupingBy always produces a map from key to a collection of elements, while toMap maps each key to a single value derived from the element. Use groupingBy when you need to collect all elements that share a key. Use toMap when you want to extract a unique value per key, such as an ID-to-object mapping.

The following table summarizes the key differences:

AspectgroupingBytoMap
Value typeCollection of elementsSingle value (often the element itself)
Duplicate keysAllowed; elements are groupedNot allowed; throws unless merge function
Null keysNot allowed by defaultNot allowed by default
Typical useGrouping records by attributeIndexing objects by unique ID

If you need to group by a key and also transform the value, you can combine groupingBy with a downstream collector like mapping. If you need a one-to-one mapping, toMap is more direct. For example, to create a map from person ID to person object, toMap is appropriate:

Map<Integer, Person> idToPerson = people.stream() .collect(Collectors.toMap(Person::id, Function.identity()));

If you try to use groupingBy for this, you would get a map of lists, which is not what you want.

Common Mistakes and Edge Cases

One frequent mistake is assuming that groupingBy preserves the encounter order of elements. It does not, unless you use a downstream collector that preserves order, such as toList() with a List implementation that does, but the map itself may not. If you need a sorted map, use a TreeMap supplier. If you need to preserve insertion order, use LinkedHashMap as the map supplier.

Another edge case is grouping by a key that is mutable. If the key object changes after being inserted into the map, the map's internal hash will become inconsistent, leading to lookup failures. Always use immutable keys, such as String or Integer, or ensure the key's hashCode() is stable.

When using downstream collectors that return a mutable collection, be aware that the collector may reuse the same collection instance across multiple groups if you write a custom collector incorrectly. The built-in collectors are safe, but if you implement your own, follow the Collector contract.

Finally, groupingBy works with parallel streams, but the default implementation is not thread-safe. Use groupingByConcurrent if you need thread safety and are willing to sacrifice order. The concurrent version uses a ConcurrentHashMap and requires the downstream collector to be concurrent-safe, which most built-in collectors are.

java collectors groupingby: Practical Usage and Code Example | RYUSLOG DEV