Collectors
Collectors provide the recipes stream.collect() uses to turn a stream of elements into lists, sets, maps, grouped summaries, joined strings, and more.
Collectors (in java.util.stream) is a factory class of ready-made Collector implementations that plug into Stream.collect(...). A Collector describes how to accumulate stream elements into a mutable result container — a list, a map, a grouped structure, a single summary value — so you rarely need to implement one yourself.
toList and toSet
Collectors.toList() collects into a List (mutability and exact implementation unspecified by the API contract, though it's typically an ArrayList); Collectors.toSet() collects into a Set (typically HashSet, so no guaranteed order and duplicates removed via equals/hashCode). Since Java 16, Stream.toList() is a convenient shorthand for an unmodifiable list, without needing Collectors.toList().
List<String> list = Stream.of("b", "a", "b").collect(Collectors.toList());
Set<String> set = Stream.of("b", "a", "b").collect(Collectors.toSet());
System.out.println(list); // [b, a, b]
System.out.println(set); // [a, b] (order not guaranteed)toMap
Collectors.toMap(keyFn, valueFn) builds a Map from each element by extracting a key and a value. If two elements produce the same key, it throws IllegalStateException by default — a three-argument overload adds a merge function to resolve collisions, and a four-argument overload additionally lets you specify the map implementation (e.g. TreeMap::new for sorted output).
List<String> words = List.of("apple", "banana", "cherry");
Map<String, Integer> lengths = words.stream()
.collect(Collectors.toMap(w -> w, String::length));
System.out.println(lengths); // {banana=6, apple=5, cherry=6} order not guaranteed
// duplicate keys need a merge function:
Map<Integer, String> byLength = words.stream()
.collect(Collectors.toMap(String::length, w -> w, (a, b) -> a + "/" + b));
System.out.println(byLength); // {5=apple, 6=banana/cherry}groupingBy
Collectors.groupingBy(classifier) partitions elements into a Map<K, List<T>>, where K is whatever the classifier function returns, and each key's value is a list of elements that mapped to it. A downstream collector can replace the default list-collection step (see below).
List<String> words = List.of("apple", "banana", "avocado", "blueberry", "cherry");
Map<Character, List<String>> byFirstLetter = words.stream()
.collect(Collectors.groupingBy(w -> w.charAt(0)));
System.out.println(byFirstLetter);
// {a=[apple, avocado], b=[banana, blueberry], c=[cherry]}partitioningBy
Collectors.partitioningBy(predicate) is a specialized two-bucket grouping: it always produces a Map<Boolean, List<T>> with exactly two keys, true and false, even if one bucket ends up empty. Use it instead of groupingBy when the classifier is naturally a yes/no predicate — it is typically implemented more efficiently for that binary case.
List<Integer> nums = List.of(1, 2, 3, 4, 5, 6);
Map<Boolean, List<Integer>> evenOdd = nums.stream()
.collect(Collectors.partitioningBy(n -> n % 2 == 0));
System.out.println(evenOdd); // {false=[1, 3, 5], true=[2, 4, 6]}joining
Collectors.joining() concatenates a stream of CharSequence elements into one String. Overloads accept a delimiter, and optionally a prefix and suffix.
String csv = Stream.of("a", "b", "c").collect(Collectors.joining(", ", "[", "]"));
System.out.println(csv); // [a, b, c]mapping — Downstream Transformation
Collectors.mapping(function, downstream) is a downstream collector adapter: it applies a mapping function to each element before feeding it into another collector. It's how you combine grouping with a transformation of the grouped values — e.g. group words by length, but collect just their uppercase forms rather than the words themselves.
List<String> words = List.of("ant", "bee", "cat", "dog", "emu");
Map<Integer, List<String>> byLengthUpper = words.stream()
.collect(Collectors.groupingBy(String::length,
Collectors.mapping(String::toUpperCase, Collectors.toList())));
System.out.println(byLengthUpper); // {3=[ANT, BEE, CAT, DOG, EMU]}reducing
Collectors.reducing(...) mirrors Stream.reduce but as a collector, so it can be used as a downstream collector (e.g. inside groupingBy) — something the plain Stream.reduce terminal operation cannot do, since you can only call one terminal operation per stream.
List<Integer> nums = List.of(1, 2, 3, 4, 5);
Optional<Integer> product = nums.stream()
.collect(Collectors.reducing((a, b) -> a * b));
System.out.println(product.get()); // 120Downstream Collectors in groupingBy
groupingBy accepts a second argument, a downstream collector, that decides how each group's elements are combined — instead of always producing a List. Common downstream collectors: counting() (group sizes), summingInt/averagingInt (numeric aggregates per group), toSet(), joining(), or nested groupingBy for multi-level grouping.
List<String> words = List.of("apple", "avocado", "banana", "blueberry", "cherry");
Map<Character, Long> countByLetter = words.stream()
.collect(Collectors.groupingBy(w -> w.charAt(0), Collectors.counting()));
System.out.println(countByLetter); // {a=2, b=2, c=1}
Map<Character, Double> avgLenByLetter = words.stream()
.collect(Collectors.groupingBy(w -> w.charAt(0), Collectors.averagingInt(String::length)));
System.out.println(avgLenByLetter); // {a=6.0, b=7.5, c=6.0}Duplicate-Key Handling in toMap
The most common toMap bug is a IllegalStateException: Duplicate key when two elements produce the same key with the two-argument form. This isn't a defect — it's the collector refusing to silently drop data. Decide explicitly: keep the first, keep the last, merge, or fail loudly (the default).
List<String> words = List.of("cat", "car", "dog");
// keys by first letter collide for cat/car
Map<Character, String> keepFirst = words.stream()
.collect(Collectors.toMap(w -> w.charAt(0), w -> w, (existing, incoming) -> existing));
System.out.println(keepFirst); // {c=cat, d=dog}Choosing the Right Collector
Need Collector
---- ---------
Simple list toList() / Collectors.toList()
Unique elements toSet()
Key->value lookup toMap(keyFn, valFn[, mergeFn][, mapSupplier])
Group by category groupingBy(classifier[, downstream])
Split into two buckets partitioningBy(predicate)
Concatenate strings joining([delim[, prefix, suffix]])
Transform then collect mapping(fn, downstream)
Custom accumulation reducing(...) or a custom CollectorKey Takeaways
Collectors are the recipes collect() uses; you rarely write a Collector from scratch.
toMap throws on duplicate keys unless you supply a merge function.
groupingBy defaults to Map<K, List<T>> but accepts a downstream collector to change how each group's values are combined.
partitioningBy always yields exactly two keys, true and false, even if one bucket is empty.
mapping and reducing are downstream collectors — they compose with groupingBy where a plain terminal operation cannot.
Map<Character, String> m = words.stream() .collect(Collectors.toMap(w -> w.charAt(0), w -> w)); // throws on duplicate first letters
Map<Character, String> m = words.stream() .collect(Collectors.toMap(w -> w.charAt(0), w -> w, (a, b) -> a + "," + b));
The two-arg toMap has no strategy for colliding keys and throws IllegalStateException; always supply a merge function when key collisions are possible.
Map<Boolean, List<Integer>> m = nums.stream() .collect(Collectors.groupingBy(n -> n % 2 == 0)); // works, but semantically odd
Map<Boolean, List<Integer>> m = nums.stream() .collect(Collectors.partitioningBy(n -> n % 2 == 0));
For a boolean classifier, partitioningBy is the intent-revealing, purpose-built collector and always yields both true and false keys, even if one bucket is empty.
List<String> upper = words.stream() .collect(Collectors.groupingBy(String::length)).values().stream() .flatMap(List::stream).map(String::toUpperCase).toList(); // roundabout
Map<Integer, List<String>> byLen = words.stream() .collect(Collectors.groupingBy(String::length, Collectors.mapping(String::toUpperCase, Collectors.toList())));
Use mapping() as a downstream collector to transform grouped elements in place, instead of grouping first and post-processing separately.
What does Collectors.toMap do when two elements map to the same key, and how do you handle it?
By default it throws IllegalStateException on the second colliding key. Handle it by passing a three-argument merge function (existing, incoming) -> resolvedValue that decides how to combine or choose between the colliding values.
How does partitioningBy differ from groupingBy with a boolean classifier?
partitioningBy is specialized for a true/false predicate: it always produces exactly two map entries (true and false), even if one bucket is empty, and is typically implemented more efficiently for that binary case. groupingBy is general-purpose and only creates keys that actually occur.
Why would you use Collectors.mapping() as a downstream collector inside groupingBy?
To transform each grouped element (e.g. extract a field or apply a function) before it's accumulated into the group's result, without a separate post-processing pass over the grouped map.
What's the difference between Collectors.reducing() and the Stream.reduce() terminal operation?
They do the same conceptual reduction, but reducing() is a Collector, so it can be used as a downstream collector (e.g. nested inside groupingBy) — something the standalone reduce() terminal operation can't do, since a stream only supports one terminal operation.
What collector would you use to count how many elements fall into each group?
groupingBy(classifier, Collectors.counting()) — it groups by the classifier and, per group, counts elements instead of collecting them into a list.
Does Collectors.toSet() guarantee any ordering or that duplicates use identity comparison?
No — it typically returns a HashSet-backed set (no ordering guarantee), and duplicates are removed via equals()/hashCode(), not reference identity.
How would you produce a comma-joined, sorted, unique list of names as a single String?
names.stream().distinct().sorted().collect(Collectors.joining(", ")) — distinct removes duplicates, sorted orders them, and joining concatenates with a delimiter.
Write code that groups a List<String> of words by their length and, for each length, joins the words with a comma.
List<String> words = List.of("ox", "cat", "dog", "ant", "owl"); Map<Integer, String> byLen = words.stream() .collect(Collectors.groupingBy(String::length, Collectors.joining(", "))); System.out.println(byLen); // {2=ox, owl=... actually 3=cat, dog, ant 2=ox, owl}
{2=ox, owl, 3=cat, dog, ant} (exact map iteration order not guaranteed) — groupingBy buckets by length, and joining concatenates each bucket's words with ', '.
What happens when this code runs, and why?
It throws java.lang.IllegalStateException: Duplicate key a. The two-argument toMap has no merge strategy, and 'a' appears twice as a key, so the collector refuses to silently overwrite.
Write code that partitions a List<Integer> into passing (>= 60) and failing (< 60) scores, and prints the count in each bucket.
List<Integer> scores = List.of(45, 82, 91, 55, 60, 78); Map<Boolean, List<Integer>> result = scores.stream() .collect(Collectors.partitioningBy(s -> s >= 60)); System.out.println("pass=" + result.get(true).size() + " fail=" + result.get(false).size());
pass=4 fail=2 — partitioningBy always yields both true and false keys; here 82, 91, 60, 78 pass and 45, 55 fail.
What does this print?
{1=1, 2=2, 3=1} — groupingBy buckets by string length (1, 2, 2, 3), and counting() reports how many elements fall in each length bucket.
Map<String, Integer> m = Stream.of("a", "bb", "a")
.collect(Collectors.toMap(s -> s, String::length));Map<Integer, Long> byLenCount = Stream.of("a", "bb", "cc", "ddd")
.collect(Collectors.groupingBy(String::length, Collectors.counting()));
System.out.println(byLenCount);