A stream pipeline has to end somewhere. collect is the general-purpose ending, and
the Collectors class supplies the recipes — turn this stream into a list, a map, a
grouped report, or one joined string.
There are dozens of them and you do not need dozens. Six do almost all the work in practice, and
the one genuinely worth studying is groupingBy, because it replaces the loop-with-a-map
that every codebase has written by hand at least once.
The basic three
List<String> names = List.of("Ana", "Bo", "Cy", "Bo");
class Demo {
void run(List<String> names) {
List<String> list = names.stream().collect(Collectors.toList());
Set<String> set = names.stream().collect(Collectors.toSet());
TreeSet<String> sorted = names.stream()
.collect(Collectors.toCollection(TreeSet::new));
System.out.println(list.size()); // 4
System.out.println(set.size()); // 3 — Bo appears once
System.out.println(sorted.first()); // Ana
}
}
For a plain list, prefer .toList() — added in Java 16, shorter, and it returns an
unmodifiable list, which is usually what you want anyway. Reach for
Collectors.toCollection when you need a specific implementation such as a
TreeSet or a LinkedList.
joining
List<String> names = List.of("Ana", "Bo", "Cy");
class Demo {
void run(List<String> names) {
System.out.println(names.stream().collect(Collectors.joining()));
// AnaBoCy
System.out.println(names.stream().collect(Collectors.joining(", ")));
// Ana, Bo, Cy
System.out.println(names.stream().collect(Collectors.joining(", ", "[", "]")));
// [Ana, Bo, Cy]
}
}
The three-argument form takes a delimiter, a prefix and a suffix, which covers most of the string-building you would otherwise do with a loop and a trailing-comma bug.
groupingBy — the one you will use most
record Person(String name, String city, int age) { }
class Demo {
void run() {
List<Person> people = List.of(
new Person("Ana", "Seattle", 30),
new Person("Bo", "Seattle", 25),
new Person("Cy", "Nuku'alofa", 35));
Map<String, List<Person>> byCity =
people.stream().collect(Collectors.groupingBy(Person::city));
System.out.println(byCity.get("Seattle").size()); // 2
// A second collector says what to do with each group instead of listing it
Map<String, Long> countByCity = people.stream()
.collect(Collectors.groupingBy(Person::city, Collectors.counting()));
System.out.println(countByCity.get("Seattle")); // 2
Map<String, List<String>> namesByCity = people.stream()
.collect(Collectors.groupingBy(Person::city,
Collectors.mapping(Person::name, Collectors.toList())));
System.out.println(namesByCity.get("Seattle")); // [Ana, Bo]
Map<String, Double> avgAge = people.stream()
.collect(Collectors.groupingBy(Person::city,
Collectors.averagingInt(Person::age)));
System.out.println(avgAge.get("Seattle")); // 27.5
}
}
The second argument — the downstream collector — is the part worth learning. Without it
you get a List of everything in each group; with it you get exactly the summary you
were about to write a loop for.
toMap, and its one trap
record Person(String name, String city, int age) { }
class Demo {
void run() {
List<Person> people = List.of(
new Person("Ana", "Seattle", 30),
new Person("Bo", "Seattle", 25));
Map<String, Integer> ages = people.stream()
.collect(Collectors.toMap(Person::name, Person::age));
System.out.println(ages.get("Ana")); // 30
// Two people in one city: the two-argument form would THROW here
Map<String, Integer> byCity = people.stream()
.collect(Collectors.toMap(Person::city, Person::age, (a, b) -> a + b));
System.out.println(byCity.get("Seattle")); // 55 — merged instead
}
}
A duplicate key throws IllegalStateException, it does not overwrite.
That surprises everyone once. The third argument is a merge function deciding what happens when two
elements produce the same key — (a, b) -> b to keep the last, (a, b) ->
a to keep the first, or real arithmetic as above.
Counting and statistics
List<Integer> numbers = List.of(3, 1, 4, 1, 5);
class Demo {
void run(List<Integer> numbers) {
System.out.println(numbers.stream().collect(Collectors.counting())); // 5
System.out.println(numbers.stream().collect(Collectors.summingInt(n -> n))); // 14
System.out.println(numbers.stream().collect(Collectors.averagingInt(n -> n))); // 2.8
IntSummaryStatistics stats = numbers.stream()
.collect(Collectors.summarizingInt(n -> n));
System.out.println(stats.getMin() + ".." + stats.getMax() + " avg " + stats.getAverage());
// 1..5 avg 2.8
}
}
summarizing* is the efficient choice when you want several of count, sum, min, max
and average — it walks the data once instead of once per question.
partitioningBy
List<Integer> numbers = List.of(1, 2, 3, 4, 5, 6);
class Demo {
void run(List<Integer> numbers) {
Map<Boolean, List<Integer>> split = numbers.stream()
.collect(Collectors.partitioningBy(n -> n % 2 == 0));
System.out.println(split.get(true)); // [2, 4, 6]
System.out.println(split.get(false)); // [1, 3, 5]
}
}
It is groupingBy with a boolean key, and the difference is worth knowing: partitioning
always returns both keys, even when one side is empty. Grouping by a predicate would silently omit
the missing half and hand you a null.
Reducing and mapping downstream
Two more downstream collectors round out the set. mapping transforms each element
before it reaches the inner collector, and filtering drops some of them — importantly,
after grouping, so a group that loses everything still appears with an empty list:
record Person(String name, String city, int age) { }
class Demo {
void run() {
List<Person> people = List.of(
new Person("Ana", "Seattle", 30),
new Person("Bo", "Seattle", 17),
new Person("Cy", "Nuku'alofa", 15));
Map<String, List<Person>> adultsByCity = people.stream()
.collect(Collectors.groupingBy(Person::city,
Collectors.filtering(p -> p.age() >= 18, Collectors.toList())));
System.out.println(adultsByCity.get("Seattle").size()); // 1
System.out.println(adultsByCity.get("Nuku'alofa")); // [] — still present
// The oldest person per city
Map<String, Optional<Person>> oldest = people.stream()
.collect(Collectors.groupingBy(Person::city,
Collectors.maxBy(Comparator.comparingInt(Person::age))));
System.out.println(oldest.get("Seattle").map(Person::name).orElse("none")); // Ana
}
}
Note the difference from filtering before the grouping: stream().filter(...) first
would drop Nuku'alofa from the map entirely, because nothing from that city survives to create the
key. Which behaviour you want depends on whether an empty group is meaningful — in a report, it
usually is.
When not to use a collector
Several collectors have a shorter equivalent, and the shorter one is clearer:
List<String> names = List.of("Ana", "Bo");
class Demo {
void run(List<String> names) {
names.stream().collect(Collectors.toList()); // works
names.stream().toList(); // better — Java 16+
names.stream().collect(Collectors.counting()); // works
System.out.println(names.stream().count()); // better
List.of(1, 2).stream().collect(Collectors.summingInt(n -> n)); // works
System.out.println(List.of(1, 2).stream().mapToInt(n -> n).sum()); // better
}
}
Collectors earns its place for grouping, joining, partitioning and building maps.
For counting and summing, the stream already has a method that says it more directly.
Next
Optional is next — the return type that says "there might be nothing here" in the signature instead of in a comment.