Collectors class

July 9, 20264 min readUpdated 8/20/2026

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.