Collectors class

August 5, 201920 min readUpdated 2/28/2026

1. What is the Collectors Class?

When you use the Stream API, the collect() terminal operation is how you transform a stream back into a concrete data structure — a List, a Set, a Map, a String, or even a custom container. But collect() does not know how to build these structures on its own. That is where Collectors come in.

The java.util.stream.Collectors class is a utility class (introduced in Java 8) that provides dozens of factory methods for creating Collector objects. Each Collector defines three things:

  • Supplier — how to create the result container (e.g., new ArrayList<>())
  • Accumulator — how to add an element to the container (e.g., list.add(element))
  • Combiner — how to merge two containers (needed for parallel streams)

You rarely need to think about these three parts because the pre-built Collectors handle it. Here is the big picture:

Category Collectors
Collection builders toList(), toSet(), toMap(), toCollection(), toUnmodifiableList()
String builders joining()
Grouping groupingBy(), partitioningBy()
Aggregation counting(), summingInt(), averagingDouble(), summarizingInt()
Reduction reducing(), mapping(), flatMapping(), filtering()
Min/Max minBy(), maxBy()
Custom Collector.of()
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;

public class CollectorsIntro {
    public static void main(String[] args) {
        List<String> names = List.of("Alice", "Bob", "Charlie", "Alice", "David");

        // Without Collectors -- manual collect (verbose)
        List<String> manual = names.stream()
            .filter(n -> n.length() > 3)
            .collect(
                () -> new java.util.ArrayList<>(),  // supplier
                (list, item) -> list.add(item),     // accumulator
                (list1, list2) -> list1.addAll(list2) // combiner
            );

        // With Collectors -- one method call
        List<String> easy = names.stream()
            .filter(n -> n.length() > 3)
            .collect(Collectors.toList());

        System.out.println("Manual: " + manual);
        System.out.println("Easy:   " + easy);
        // Both output: [Alice, Charlie, Alice, David]
    }
}

2. Basic Collectors: toList(), toSet(), toCollection()

These are the workhorses of the Collectors class — used in the vast majority of stream pipelines.

Collector Returns Duplicates Order
toList() List<T> Allowed Preserved
toSet() Set<T> Removed Not guaranteed
toUnmodifiableList() (Java 10+) List<T> Allowed Preserved, immutable
toUnmodifiableSet() (Java 10+) Set<T> Removed Not guaranteed, immutable
toCollection(Supplier) Any Collection Depends on type Depends on type
import java.util.*;
import java.util.stream.Collectors;

public class BasicCollectors {
    public static void main(String[] args) {
        List<String> names = List.of("Charlie", "Alice", "Bob", "Alice", "David", "Bob");

        // toList() -- ArrayList (mutable)
        List<String> list = names.stream()
            .filter(n -> n.length() > 3)
            .collect(Collectors.toList());
        list.add("Extra"); // works -- list is mutable
        System.out.println("toList: " + list);
        // Output: toList: [Charlie, Alice, Alice, David, Extra]

        // toSet() -- HashSet (no duplicates)
        Set<String> set = names.stream()
            .collect(Collectors.toSet());
        System.out.println("toSet: " + set);
        // Output: toSet: [Alice, Bob, Charlie, David] (order may vary)

        // toUnmodifiableList() -- Java 10+ (immutable)
        List<String> immutable = names.stream()
            .filter(n -> n.length() > 3)
            .collect(Collectors.toUnmodifiableList());
        System.out.println("toUnmodifiableList: " + immutable);
        // immutable.add("Fail"); // throws UnsupportedOperationException

        // toUnmodifiableSet() -- Java 10+ (immutable, no duplicates)
        Set<String> immutableSet = names.stream()
            .collect(Collectors.toUnmodifiableSet());
        System.out.println("toUnmodifiableSet: " + immutableSet);

        // toCollection() -- specify the exact collection type
        TreeSet<String> treeSet = names.stream()
            .collect(Collectors.toCollection(TreeSet::new));
        System.out.println("TreeSet (sorted): " + treeSet);
        // Output: TreeSet (sorted): [Alice, Bob, Charlie, David]

        LinkedList<String> linkedList = names.stream()
            .collect(Collectors.toCollection(LinkedList::new));
        System.out.println("LinkedList: " + linkedList);

        // Java 16+: Stream.toList() shorthand (returns unmodifiable list)
        List<String> java16List = names.stream()
            .filter(n -> n.startsWith("A"))
            .toList();
        System.out.println("Stream.toList(): " + java16List);
        // Output: Stream.toList(): [Alice, Alice]
    }
}

3. toMap()

Collectors.toMap() builds a Map from stream elements. It requires two functions: one to extract the key, and one to extract the value. It has three overloaded forms to handle duplicate keys and specify the Map implementation.

3.1 Basic toMap

import java.util.*;
import java.util.stream.Collectors;

public class ToMapBasic {
    public static void main(String[] args) {
        List<String> names = List.of("Alice", "Bob", "Charlie", "David");

        // Basic: name -> length
        Map<String, Integer> nameLengths = names.stream()
            .collect(Collectors.toMap(
                name -> name,        // key mapper
                String::length       // value mapper
            ));
        System.out.println("Name lengths: " + nameLengths);
        // Output: Name lengths: {Alice=5, Bob=3, Charlie=7, David=5}

        // Map from index to value
        List<String> colors = List.of("Red", "Green", "Blue");
        Map<Integer, String> indexed = new HashMap<>();
        for (int i = 0; i < colors.size(); i++) {
            indexed.put(i, colors.get(i));
        }
        // Or with streams using an AtomicInteger
        java.util.concurrent.atomic.AtomicInteger counter = new java.util.concurrent.atomic.AtomicInteger(0);
        Map<Integer, String> indexedStream = colors.stream()
            .collect(Collectors.toMap(
                color -> counter.getAndIncrement(),
                color -> color
            ));
        System.out.println("Indexed: " + indexedStream);
        // Output: Indexed: {0=Red, 1=Green, 2=Blue}
    }
}

3.2 Handling Duplicate Keys (Merge Function)

If two elements produce the same key, toMap() throws IllegalStateException by default. To handle duplicates, provide a merge function as the third argument.

import java.util.*;
import java.util.stream.Collectors;

public class ToMapDuplicates {
    public static void main(String[] args) {
        List<String> names = List.of("Alice", "Anna", "Bob", "Brian", "Charlie");

        // Group by first letter -- FAILS if duplicate keys
        // Map<Character, String> bad = names.stream()
        //     .collect(Collectors.toMap(n -> n.charAt(0), n -> n));
        // IllegalStateException: Duplicate key A (attempted merging Alice and Anna)

        // Handle duplicates: keep the first value
        Map<Character, String> keepFirst = names.stream()
            .collect(Collectors.toMap(
                n -> n.charAt(0),     // key
                n -> n,              // value
                (existing, replacement) -> existing  // merge: keep first
            ));
        System.out.println("Keep first: " + keepFirst);
        // Output: Keep first: {A=Alice, B=Bob, C=Charlie}

        // Handle duplicates: keep the last value
        Map<Character, String> keepLast = names.stream()
            .collect(Collectors.toMap(
                n -> n.charAt(0),
                n -> n,
                (existing, replacement) -> replacement  // merge: keep last
            ));
        System.out.println("Keep last: " + keepLast);
        // Output: Keep last: {A=Anna, B=Brian, C=Charlie}

        // Handle duplicates: concatenate values
        Map<Character, String> concat = names.stream()
            .collect(Collectors.toMap(
                n -> n.charAt(0),
                n -> n,
                (a, b) -> a + ", " + b  // merge: join with comma
            ));
        System.out.println("Concatenated: " + concat);
        // Output: Concatenated: {A=Alice, Anna, B=Bob, Brian, C=Charlie}

        // Count occurrences using toMap
        List<String> words = List.of("apple", "banana", "apple", "cherry", "banana", "apple");
        Map<String, Integer> wordCount = words.stream()
            .collect(Collectors.toMap(
                w -> w,
                w -> 1,
                Integer::sum
            ));
        System.out.println("Word counts: " + wordCount);
        // Output: Word counts: {apple=3, banana=2, cherry=1}
    }
}

3.3 Controlling the Map Type

By default, toMap() returns a HashMap. The four-argument form lets you specify a different Map implementation, such as TreeMap (sorted) or LinkedHashMap (insertion order).

import java.util.*;
import java.util.stream.Collectors;

public class ToMapType {
    public static void main(String[] args) {
        List<String> names = List.of("Charlie", "Alice", "Bob", "David");

        // TreeMap -- keys sorted alphabetically
        TreeMap<String, Integer> sorted = names.stream()
            .collect(Collectors.toMap(
                n -> n,
                String::length,
                (a, b) -> a,          // merge function (required for 4-arg form)
                TreeMap::new           // map factory
            ));
        System.out.println("TreeMap: " + sorted);
        // Output: TreeMap: {Alice=5, Bob=3, Charlie=7, David=5}

        // LinkedHashMap -- preserves insertion order
        LinkedHashMap<String, Integer> ordered = names.stream()
            .collect(Collectors.toMap(
                n -> n,
                String::length,
                (a, b) -> a,
                LinkedHashMap::new
            ));
        System.out.println("LinkedHashMap: " + ordered);
        // Output: LinkedHashMap: {Charlie=7, Alice=5, Bob=3, David=5}
    }
}

4. joining()

Collectors.joining() concatenates CharSequence elements (Strings) into a single String. It has three overloaded forms.

import java.util.List;
import java.util.stream.Collectors;

public class JoiningCollector {
    public static void main(String[] args) {
        List<String> languages = List.of("Java", "Python", "Go", "Rust", "C++");

        // joining() -- no separator
        String noSep = languages.stream().collect(Collectors.joining());
        System.out.println("No separator: " + noSep);
        // Output: No separator: JavaPythonGoRustC++

        // joining(delimiter) -- with separator
        String commaSep = languages.stream().collect(Collectors.joining(", "));
        System.out.println("Comma: " + commaSep);
        // Output: Comma: Java, Python, Go, Rust, C++

        // joining(delimiter, prefix, suffix)
        String formatted = languages.stream()
            .collect(Collectors.joining(" | ", "Languages: [", "]"));
        System.out.println(formatted);
        // Output: Languages: [Java | Python | Go | Rust | C++]

        // Practical: CSV row
        List<String> fields = List.of("John", "Doe", "john@example.com", "555-1234");
        String csvRow = fields.stream().collect(Collectors.joining(","));
        System.out.println("CSV: " + csvRow);
        // Output: CSV: John,Doe,john@example.com,555-1234

        // Practical: SQL IN clause
        List<Integer> ids = List.of(101, 205, 310, 422);
        String sql = ids.stream()
            .map(String::valueOf)
            .collect(Collectors.joining(", ", "SELECT * FROM users WHERE id IN (", ");"));
        System.out.println("SQL: " + sql);
        // Output: SQL: SELECT * FROM users WHERE id IN (101, 205, 310, 422);

        // Practical: HTML list
        List<String> items = List.of("Home", "About", "Contact");
        String html = items.stream()
            .map(item -> "  <li>" + item + "</li>")
            .collect(Collectors.joining("\n", "<ul>\n", "\n</ul>"));
        System.out.println(html);

        // joining() on empty stream returns empty prefix+suffix
        String empty = List.<String>of().stream()
            .collect(Collectors.joining(", ", "[", "]"));
        System.out.println("Empty: " + empty);
        // Output: Empty: []
    }
}

5. groupingBy()

Collectors.groupingBy() is the stream equivalent of SQL’s GROUP BY. It classifies elements by a key and groups them into a Map<K, List<T>>. With downstream collectors, you can perform aggregations within each group.

5.1 Single-Level Grouping

import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

public class GroupingByBasic {

    record Employee(String name, String department, double salary) {}

    public static void main(String[] args) {
        List<Employee> employees = List.of(
            new Employee("Alice", "Engineering", 95000),
            new Employee("Bob", "Engineering", 110000),
            new Employee("Charlie", "Marketing", 72000),
            new Employee("Diana", "Sales", 68000),
            new Employee("Eve", "Engineering", 125000),
            new Employee("Frank", "Marketing", 78000),
            new Employee("Grace", "Sales", 71000)
        );

        // Group by department
        Map<String, List<Employee>> byDept = employees.stream()
            .collect(Collectors.groupingBy(Employee::department));

        byDept.forEach((dept, emps) -> {
            System.out.println(dept + ":");
            emps.forEach(e -> System.out.println("  " + e.name()));
        });
        // Output:
        // Engineering:
        //   Alice
        //   Bob
        //   Eve
        // Marketing:
        //   Charlie
        //   Frank
        // Sales:
        //   Diana
        //   Grace

        // Group strings by length
        List<String> words = List.of("cat", "dog", "fish", "bird", "ant", "fox", "bear");
        Map<Integer, List<String>> byLength = words.stream()
            .collect(Collectors.groupingBy(String::length));
        System.out.println("By length: " + byLength);
        // Output: By length: {3=[cat, dog, ant, fox], 4=[fish, bird, bear]}
    }
}

5.2 groupingBy with Downstream Collectors

The real power of groupingBy() comes from the second argument — a downstream collector that processes each group. Instead of getting List<T>, you can count, sum, average, or further transform each group.

import java.util.*;
import java.util.stream.Collectors;

public class GroupingByDownstream {

    record Employee(String name, String department, double salary) {}

    public static void main(String[] args) {
        List<Employee> employees = List.of(
            new Employee("Alice", "Engineering", 95000),
            new Employee("Bob", "Engineering", 110000),
            new Employee("Charlie", "Marketing", 72000),
            new Employee("Diana", "Sales", 68000),
            new Employee("Eve", "Engineering", 125000),
            new Employee("Frank", "Marketing", 78000),
            new Employee("Grace", "Sales", 71000)
        );

        // Count per group
        Map<String, Long> countByDept = employees.stream()
            .collect(Collectors.groupingBy(Employee::department, Collectors.counting()));
        System.out.println("Count: " + countByDept);
        // Output: Count: {Engineering=3, Marketing=2, Sales=2}

        // Average salary per department
        Map<String, Double> avgByDept = employees.stream()
            .collect(Collectors.groupingBy(
                Employee::department,
                Collectors.averagingDouble(Employee::salary)
            ));
        System.out.println("Average salary: " + avgByDept);

        // Sum of salaries per department
        Map<String, Double> totalByDept = employees.stream()
            .collect(Collectors.groupingBy(
                Employee::department,
                Collectors.summingDouble(Employee::salary)
            ));
        System.out.println("Total salary: " + totalByDept);

        // Max salary per department
        Map<String, Optional<Employee>> topEarner = employees.stream()
            .collect(Collectors.groupingBy(
                Employee::department,
                Collectors.maxBy(Comparator.comparingDouble(Employee::salary))
            ));
        topEarner.forEach((dept, emp) ->
            emp.ifPresent(e -> System.out.println(dept + " top earner: " + e.name())));

        // Map to names only (mapping downstream)
        Map<String, List<String>> namesByDept = employees.stream()
            .collect(Collectors.groupingBy(
                Employee::department,
                Collectors.mapping(Employee::name, Collectors.toList())
            ));
        System.out.println("Names: " + namesByDept);
        // Output: Names: {Engineering=[Alice, Bob, Eve], Marketing=[Charlie, Frank], Sales=[Diana, Grace]}

        // Join names as a comma-separated string
        Map<String, String> joinedNames = employees.stream()
            .collect(Collectors.groupingBy(
                Employee::department,
                Collectors.mapping(Employee::name, Collectors.joining(", "))
            ));
        System.out.println("Joined: " + joinedNames);
        // Output: Joined: {Engineering=Alice, Bob, Eve, Marketing=Charlie, Frank, Sales=Diana, Grace}

        // Collect names into a Set (no duplicates)
        Map<String, Set<String>> nameSetByDept = employees.stream()
            .collect(Collectors.groupingBy(
                Employee::department,
                Collectors.mapping(Employee::name, Collectors.toSet())
            ));
        System.out.println("Name sets: " + nameSetByDept);
    }
}

5.3 Multi-Level Grouping

You can nest groupingBy() calls to create multi-level groupings — similar to SQL’s GROUP BY col1, col2.

import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

public class MultiLevelGrouping {

    record Employee(String name, String department, String level, double salary) {}

    public static void main(String[] args) {
        List<Employee> employees = List.of(
            new Employee("Alice", "Engineering", "Senior", 120000),
            new Employee("Bob", "Engineering", "Junior", 75000),
            new Employee("Charlie", "Engineering", "Senior", 115000),
            new Employee("Diana", "Marketing", "Junior", 60000),
            new Employee("Eve", "Marketing", "Senior", 85000),
            new Employee("Frank", "Sales", "Junior", 55000),
            new Employee("Grace", "Sales", "Senior", 80000),
            new Employee("Hannah", "Sales", "Junior", 58000)
        );

        // Two-level grouping: department -> level -> list of employees
        Map<String, Map<String, List<Employee>>> twoLevel = employees.stream()
            .collect(Collectors.groupingBy(
                Employee::department,
                Collectors.groupingBy(Employee::level)
            ));

        twoLevel.forEach((dept, levels) -> {
            System.out.println(dept + ":");
            levels.forEach((level, emps) -> {
                System.out.println("  " + level + ":");
                emps.forEach(e -> System.out.println("    " + e.name() + " - $" + e.salary()));
            });
        });

        // Two-level grouping with counting
        Map<String, Map<String, Long>> headcount = employees.stream()
            .collect(Collectors.groupingBy(
                Employee::department,
                Collectors.groupingBy(Employee::level, Collectors.counting())
            ));
        System.out.println("\nHeadcount: " + headcount);
        // Output: Headcount: {Engineering={Junior=1, Senior=2}, Marketing={Junior=1, Senior=1}, Sales={Junior=2, Senior=1}}

        // Two-level grouping with average salary
        Map<String, Map<String, Double>> avgSalary = employees.stream()
            .collect(Collectors.groupingBy(
                Employee::department,
                Collectors.groupingBy(
                    Employee::level,
                    Collectors.averagingDouble(Employee::salary)
                )
            ));
        System.out.println("\nAvg salary: " + avgSalary);
    }
}

6. partitioningBy()

Collectors.partitioningBy() is a special case of groupingBy() that splits elements into exactly two groups based on a predicate: true and false. The result is always a Map<Boolean, List<T>> with both keys present (even if one group is empty).

import java.util.*;
import java.util.stream.Collectors;

public class PartitioningByExample {
    public static void main(String[] args) {
        List<Integer> numbers = List.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);

        // Partition into even and odd
        Map<Boolean, List<Integer>> evenOdd = numbers.stream()
            .collect(Collectors.partitioningBy(n -> n % 2 == 0));
        System.out.println("Even: " + evenOdd.get(true));
        System.out.println("Odd:  " + evenOdd.get(false));
        // Output: Even: [2, 4, 6, 8, 10]
        //         Odd:  [1, 3, 5, 7, 9]

        // Partition with downstream counting
        Map<Boolean, Long> evenOddCount = numbers.stream()
            .collect(Collectors.partitioningBy(
                n -> n % 2 == 0,
                Collectors.counting()
            ));
        System.out.println("Even count: " + evenOddCount.get(true));   // 5
        System.out.println("Odd count:  " + evenOddCount.get(false));  // 5

        // Partition students into pass/fail
        record Student(String name, int score) {}

        List<Student> students = List.of(
            new Student("Alice", 85), new Student("Bob", 42),
            new Student("Charlie", 91), new Student("Diana", 58),
            new Student("Eve", 73), new Student("Frank", 35)
        );

        int passingScore = 60;
        Map<Boolean, List<Student>> passFail = students.stream()
            .collect(Collectors.partitioningBy(s -> s.score() >= passingScore));

        System.out.println("\nPassing:");
        passFail.get(true).forEach(s ->
            System.out.println("  " + s.name() + ": " + s.score()));
        System.out.println("Failing:");
        passFail.get(false).forEach(s ->
            System.out.println("  " + s.name() + ": " + s.score()));

        // Partition with mapping downstream
        Map<Boolean, List<String>> passFailNames = students.stream()
            .collect(Collectors.partitioningBy(
                s -> s.score() >= passingScore,
                Collectors.mapping(Student::name, Collectors.toList())
            ));
        System.out.println("Pass names: " + passFailNames.get(true));
        System.out.println("Fail names: " + passFailNames.get(false));

        // Empty partition -- both keys still present
        Map<Boolean, List<Integer>> allPass = List.of(100, 90, 80).stream()
            .collect(Collectors.partitioningBy(n -> n >= 60));
        System.out.println("True:  " + allPass.get(true));   // [100, 90, 80]
        System.out.println("False: " + allPass.get(false));   // [] (empty, but key exists)
    }
}

7. Counting and Statistics

Collectors provides several methods for numeric aggregation. These are most commonly used as downstream collectors inside groupingBy().

Collector Returns Description
counting() Long Count of elements
summingInt/Long/Double() Integer/Long/Double Sum of extracted values
averagingInt/Long/Double() Double Average of extracted values
summarizingInt/Long/Double() IntSummaryStatistics Count, sum, min, max, average in one pass
minBy(Comparator) Optional<T> Minimum element
maxBy(Comparator) Optional<T> Maximum element
import java.util.*;
import java.util.stream.Collectors;

public class CountingStatsExample {

    record Product(String name, String category, double price, int quantity) {}

    public static void main(String[] args) {
        List<Product> products = List.of(
            new Product("Laptop", "Electronics", 999.99, 50),
            new Product("Phone", "Electronics", 699.99, 200),
            new Product("Tablet", "Electronics", 449.99, 100),
            new Product("Desk", "Furniture", 299.99, 30),
            new Product("Chair", "Furniture", 199.99, 80),
            new Product("Notebook", "Office", 4.99, 1000),
            new Product("Pen", "Office", 1.99, 5000)
        );

        // counting() -- total number of products
        long totalProducts = products.stream()
            .collect(Collectors.counting());
        System.out.println("Total products: " + totalProducts);

        // counting() as downstream -- products per category
        Map<String, Long> countPerCategory = products.stream()
            .collect(Collectors.groupingBy(Product::category, Collectors.counting()));
        System.out.println("Per category: " + countPerCategory);
        // Output: Per category: {Electronics=3, Furniture=2, Office=2}

        // summingDouble() -- total inventory value
        double totalValue = products.stream()
            .collect(Collectors.summingDouble(p -> p.price() * p.quantity()));
        System.out.printf("Total inventory value: $%,.2f%n", totalValue);

        // averagingDouble() -- average price
        double avgPrice = products.stream()
            .collect(Collectors.averagingDouble(Product::price));
        System.out.printf("Average price: $%,.2f%n", avgPrice);

        // summarizingDouble() -- all stats in one pass
        DoubleSummaryStatistics priceStats = products.stream()
            .collect(Collectors.summarizingDouble(Product::price));
        System.out.println("\nPrice statistics:");
        System.out.printf("  Count: %d%n", priceStats.getCount());
        System.out.printf("  Sum:   $%,.2f%n", priceStats.getSum());
        System.out.printf("  Min:   $%,.2f%n", priceStats.getMin());
        System.out.printf("  Max:   $%,.2f%n", priceStats.getMax());
        System.out.printf("  Avg:   $%,.2f%n", priceStats.getAverage());

        // summarizingDouble per category
        Map<String, DoubleSummaryStatistics> statsByCategory = products.stream()
            .collect(Collectors.groupingBy(
                Product::category,
                Collectors.summarizingDouble(Product::price)
            ));
        System.out.println("\nStats by category:");
        statsByCategory.forEach((cat, stats) ->
            System.out.printf("  %s: count=%d, avg=$%,.2f, max=$%,.2f%n",
                cat, stats.getCount(), stats.getAverage(), stats.getMax()));

        // minBy and maxBy
        Optional<Product> cheapest = products.stream()
            .collect(Collectors.minBy(Comparator.comparingDouble(Product::price)));
        cheapest.ifPresent(p -> System.out.println("\nCheapest: " + p.name() + " $" + p.price()));

        Optional<Product> mostExpensive = products.stream()
            .collect(Collectors.maxBy(Comparator.comparingDouble(Product::price)));
        mostExpensive.ifPresent(p -> System.out.println("Most expensive: " + p.name() + " $" + p.price()));
    }
}

8. Reducing and Mapping

Collectors.reducing() and Collectors.mapping() are general-purpose downstream collectors that give you fine-grained control when the built-in aggregation collectors are not enough.

8.1 Collectors.reducing()

reducing() is the Collector equivalent of Stream.reduce(). It is typically used as a downstream collector inside groupingBy() when you need a custom reduction per group.

import java.util.*;
import java.util.stream.Collectors;

public class ReducingCollector {

    record Employee(String name, String department, double salary) {}

    public static void main(String[] args) {
        List<Employee> employees = List.of(
            new Employee("Alice", "Engineering", 95000),
            new Employee("Bob", "Engineering", 110000),
            new Employee("Charlie", "Marketing", 72000),
            new Employee("Diana", "Marketing", 78000),
            new Employee("Eve", "Sales", 68000)
        );

        // reducing() with identity -- total salary per department
        Map<String, Double> totalByDept = employees.stream()
            .collect(Collectors.groupingBy(
                Employee::department,
                Collectors.reducing(0.0, Employee::salary, Double::sum)
            ));
        System.out.println("Total by dept: " + totalByDept);
        // Output: Total by dept: {Engineering=205000.0, Marketing=150000.0, Sales=68000.0}

        // reducing() without identity -- highest earner per department
        Map<String, Optional<Employee>> topPerDept = employees.stream()
            .collect(Collectors.groupingBy(
                Employee::department,
                Collectors.reducing((e1, e2) ->
                    e1.salary() > e2.salary() ? e1 : e2)
            ));
        topPerDept.forEach((dept, emp) ->
            emp.ifPresent(e -> System.out.println(dept + " top: " + e.name())));

        // reducing() to concatenate names per department
        Map<String, String> namesByDept = employees.stream()
            .collect(Collectors.groupingBy(
                Employee::department,
                Collectors.reducing("",
                    Employee::name,
                    (a, b) -> a.isEmpty() ? b : a + ", " + b)
            ));
        System.out.println("Names by dept: " + namesByDept);
    }
}

8.2 Collectors.mapping() and Collectors.flatMapping()

mapping() transforms elements before passing them to a downstream collector. flatMapping() (Java 9+) does the same but flattens the result. Both are used as downstream collectors.

import java.util.*;
import java.util.stream.Collectors;
import java.util.stream.Stream;

public class MappingCollector {

    record Employee(String name, String department, List<String> skills) {}

    public static void main(String[] args) {
        List<Employee> employees = List.of(
            new Employee("Alice", "Engineering", List.of("Java", "Python", "SQL")),
            new Employee("Bob", "Engineering", List.of("Java", "Go", "Docker")),
            new Employee("Charlie", "Marketing", List.of("SEO", "Analytics")),
            new Employee("Diana", "Marketing", List.of("Content", "SEO", "Social"))
        );

        // mapping() -- extract names per department
        Map<String, List<String>> namesByDept = employees.stream()
            .collect(Collectors.groupingBy(
                Employee::department,
                Collectors.mapping(Employee::name, Collectors.toList())
            ));
        System.out.println("Names: " + namesByDept);
        // Output: Names: {Engineering=[Alice, Bob], Marketing=[Charlie, Diana]}

        // mapping() with joining downstream
        Map<String, String> joinedByDept = employees.stream()
            .collect(Collectors.groupingBy(
                Employee::department,
                Collectors.mapping(Employee::name, Collectors.joining(", "))
            ));
        System.out.println("Joined: " + joinedByDept);

        // flatMapping() (Java 9+) -- collect all skills per department
        Map<String, Set<String>> skillsByDept = employees.stream()
            .collect(Collectors.groupingBy(
                Employee::department,
                Collectors.flatMapping(
                    e -> e.skills().stream(),
                    Collectors.toSet()
                )
            ));
        System.out.println("Skills: " + skillsByDept);
        // Output: Skills: {Engineering=[Docker, Go, Java, Python, SQL], Marketing=[Analytics, Content, SEO, Social]}

        // filtering() (Java 9+) -- filter within each group
        Map<String, List<String>> seniorDevs = employees.stream()
            .collect(Collectors.groupingBy(
                Employee::department,
                Collectors.filtering(
                    e -> e.skills().size() >= 3,
                    Collectors.mapping(Employee::name, Collectors.toList())
                )
            ));
        System.out.println("3+ skills: " + seniorDevs);
        // Output: 3+ skills: {Engineering=[Alice, Bob], Marketing=[Diana]}
    }
}

9. Custom Collectors

When the built-in collectors do not fit your needs, you can create a custom collector using Collector.of(). You provide four components:

Component Type Description
Supplier Supplier<A> Creates the mutable result container
Accumulator BiConsumer<A, T> Adds an element to the container
Combiner BinaryOperator<A> Merges two containers (for parallel streams)
Finisher Function<A, R> Transforms the container into the final result
import java.util.*;
import java.util.stream.Collector;
import java.util.stream.Collectors;

public class CustomCollectorExample {
    public static void main(String[] args) {
        List<Integer> numbers = List.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);

        // Custom collector: collect into a comma-separated string with brackets
        String result = numbers.stream()
            .collect(Collector.of(
                StringBuilder::new,                           // supplier
                (sb, num) -> {                                // accumulator
                    if (sb.length() > 0) sb.append(", ");
                    sb.append(num);
                },
                (sb1, sb2) -> {                               // combiner
                    if (sb1.length() > 0 && sb2.length() > 0) sb1.append(", ");
                    return sb1.append(sb2);
                },
                sb -> "[" + sb.toString() + "]"               // finisher
            ));
        System.out.println("Custom string: " + result);
        // Output: Custom string: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

        // Custom collector: compute running average
        double avg = numbers.stream()
            .collect(Collector.of(
                () -> new double[]{0, 0},                     // [sum, count]
                (acc, num) -> { acc[0] += num; acc[1]++; },   // accumulate
                (a1, a2) -> {                                  // combine
                    a1[0] += a2[0];
                    a1[1] += a2[1];
                    return a1;
                },
                acc -> acc[1] == 0 ? 0.0 : acc[0] / acc[1]   // finish
            ));
        System.out.println("Average: " + avg); // Output: Average: 5.5

        // Custom collector: toImmutableList using Collector.of with UNORDERED characteristic
        List<String> names = List.of("Alice", "Bob", "Charlie");
        List<String> immutable = names.stream()
            .filter(n -> n.length() > 3)
            .collect(Collector.of(
                ArrayList::new,
                ArrayList::add,
                (list1, list2) -> { list1.addAll(list2); return list1; },
                Collections::unmodifiableList
            ));
        System.out.println("Immutable: " + immutable);
        // immutable.add("Fail"); // throws UnsupportedOperationException

        // Custom collector: group into even-indexed and odd-indexed elements
        List<String> items = List.of("A", "B", "C", "D", "E", "F");
        Map<String, List<String>> evenOddIndex = items.stream()
            .collect(Collector.of(
                () -> {
                    Map<String, List<String>> map = new HashMap<>();
                    map.put("even-index", new ArrayList<>());
                    map.put("odd-index", new ArrayList<>());
                    map.put("_counter", new ArrayList<>()); // track index
                    return map;
                },
                (map, item) -> {
                    int index = map.get("_counter").size();
                    map.get("_counter").add("x");
                    if (index % 2 == 0) {
                        map.get("even-index").add(item);
                    } else {
                        map.get("odd-index").add(item);
                    }
                },
                (m1, m2) -> { m1.get("even-index").addAll(m2.get("even-index"));
                              m1.get("odd-index").addAll(m2.get("odd-index")); return m1; },
                map -> { map.remove("_counter");  return map; }
            ));
        System.out.println("Even-index: " + evenOddIndex.get("even-index"));
        System.out.println("Odd-index:  " + evenOddIndex.get("odd-index"));
        // Output: Even-index: [A, C, E]
        //         Odd-index:  [B, D, F]
    }
}

Reusable Custom Collector

For collectors you use frequently, define them as static methods so they can be imported and reused like the built-in collectors.

import java.util.*;
import java.util.stream.Collector;

public class ReusableCollector {

    // Custom collector: toLinkedList()
    public static <T> Collector<T, ?, LinkedList<T>> toLinkedList() {
        return Collector.of(
            LinkedList::new,
            LinkedList::add,
            (list1, list2) -> { list1.addAll(list2); return list1; }
        );
    }

    // Custom collector: toReversedList()
    public static <T> Collector<T, ?, List<T>> toReversedList() {
        return Collector.of(
            ArrayList::new,
            (list, item) -> list.add(0, item),
            (list1, list2) -> { list2.addAll(list1); return list2; }
        );
    }

    public static void main(String[] args) {
        List<String> names = List.of("Alice", "Bob", "Charlie", "David");

        // Use custom toLinkedList()
        LinkedList<String> linked = names.stream()
            .filter(n -> n.length() > 3)
            .collect(toLinkedList());
        System.out.println("LinkedList: " + linked);
        System.out.println("Type: " + linked.getClass().getSimpleName());
        // Output: LinkedList: [Alice, Charlie, David]
        //         Type: LinkedList

        // Use custom toReversedList()
        List<String> reversed = names.stream()
            .collect(toReversedList());
        System.out.println("Reversed: " + reversed);
        // Output: Reversed: [David, Charlie, Bob, Alice]
    }
}

10. Complete Practical Example: Sales Analytics

Let us combine everything into a realistic scenario. We have a list of sales transactions and need to perform various analytics using the full power of the Collectors class.

import java.time.LocalDate;
import java.time.Month;
import java.util.*;
import java.util.stream.Collectors;

public class SalesAnalytics {

    record Sale(String product, String category, String region,
                double amount, int quantity, LocalDate date) {}

    public static void main(String[] args) {
        List<Sale> sales = List.of(
            new Sale("Laptop",    "Electronics", "North", 999.99, 5,  LocalDate.of(2024, 1, 15)),
            new Sale("Phone",     "Electronics", "South", 699.99, 12, LocalDate.of(2024, 1, 20)),
            new Sale("Tablet",    "Electronics", "North", 449.99, 8,  LocalDate.of(2024, 2, 5)),
            new Sale("Desk",      "Furniture",   "East",  299.99, 3,  LocalDate.of(2024, 2, 10)),
            new Sale("Chair",     "Furniture",   "West",  199.99, 15, LocalDate.of(2024, 2, 14)),
            new Sale("Laptop",    "Electronics", "East",  999.99, 7,  LocalDate.of(2024, 3, 1)),
            new Sale("Phone",     "Electronics", "North", 699.99, 20, LocalDate.of(2024, 3, 5)),
            new Sale("Notebook",  "Office",      "South", 4.99,   500,LocalDate.of(2024, 3, 10)),
            new Sale("Pen",       "Office",      "West",  1.99,   1000, LocalDate.of(2024, 3, 15)),
            new Sale("Chair",     "Furniture",   "North", 199.99, 10, LocalDate.of(2024, 4, 1)),
            new Sale("Desk",      "Furniture",   "South", 299.99, 5,  LocalDate.of(2024, 4, 10)),
            new Sale("Laptop",    "Electronics", "West",  999.99, 3,  LocalDate.of(2024, 4, 20)),
            new Sale("Phone",     "Electronics", "East",  699.99, 15, LocalDate.of(2024, 5, 1)),
            new Sale("Tablet",    "Electronics", "South", 449.99, 6,  LocalDate.of(2024, 5, 10)),
            new Sale("Chair",     "Furniture",   "East",  199.99, 20, LocalDate.of(2024, 5, 15))
        );

        // ===== 1. Revenue by Category =====
        System.out.println("=== Revenue by Category ===");
        Map<String, Double> revenueByCategory = sales.stream()
            .collect(Collectors.groupingBy(
                Sale::category,
                Collectors.summingDouble(s -> s.amount() * s.quantity())
            ));
        revenueByCategory.entrySet().stream()
            .sorted(Map.Entry.<String, Double>comparingByValue().reversed())
            .forEach(e -> System.out.printf("  %-15s $%,12.2f%n", e.getKey(), e.getValue()));

        // ===== 2. Top Products by Total Revenue =====
        System.out.println("\n=== Top Products by Revenue ===");
        sales.stream()
            .collect(Collectors.groupingBy(
                Sale::product,
                Collectors.summingDouble(s -> s.amount() * s.quantity())
            ))
            .entrySet().stream()
            .sorted(Map.Entry.<String, Double>comparingByValue().reversed())
            .limit(3)
            .forEach(e -> System.out.printf("  %-15s $%,12.2f%n", e.getKey(), e.getValue()));

        // ===== 3. Average Order Value by Region =====
        System.out.println("\n=== Average Order Value by Region ===");
        Map<String, Double> avgByRegion = sales.stream()
            .collect(Collectors.groupingBy(
                Sale::region,
                Collectors.averagingDouble(s -> s.amount() * s.quantity())
            ));
        avgByRegion.forEach((region, avg) ->
            System.out.printf("  %-10s $%,10.2f%n", region, avg));

        // ===== 4. Monthly Revenue Trend =====
        System.out.println("\n=== Monthly Revenue ===");
        Map<Month, Double> monthlyRevenue = sales.stream()
            .collect(Collectors.groupingBy(
                s -> s.date().getMonth(),
                TreeMap::new,  // sorted by month
                Collectors.summingDouble(s -> s.amount() * s.quantity())
            ));
        monthlyRevenue.forEach((month, rev) ->
            System.out.printf("  %-12s $%,12.2f%n", month, rev));

        // ===== 5. Products per Category with Quantities =====
        System.out.println("\n=== Products per Category ===");
        Map<String, Map<String, Integer>> productsByCat = sales.stream()
            .collect(Collectors.groupingBy(
                Sale::category,
                Collectors.groupingBy(
                    Sale::product,
                    Collectors.summingInt(Sale::quantity)
                )
            ));
        productsByCat.forEach((cat, products) -> {
            System.out.println("  " + cat + ":");
            products.forEach((prod, qty) ->
                System.out.printf("    %-15s %,d units%n", prod, qty));
        });

        // ===== 6. Partition: High-Value vs Low-Value Transactions =====
        System.out.println("\n=== High vs Low Value Transactions ===");
        double threshold = 3000;
        Map<Boolean, Long> highLow = sales.stream()
            .collect(Collectors.partitioningBy(
                s -> s.amount() * s.quantity() >= threshold,
                Collectors.counting()
            ));
        System.out.println("  High-value (>= $" + threshold + "): " + highLow.get(true));
        System.out.println("  Low-value  (<  $" + threshold + "): " + highLow.get(false));

        // ===== 7. Region with Highest Total Revenue =====
        sales.stream()
            .collect(Collectors.groupingBy(
                Sale::region,
                Collectors.summingDouble(s -> s.amount() * s.quantity())
            ))
            .entrySet().stream()
            .max(Map.Entry.comparingByValue())
            .ifPresent(e -> System.out.printf(
                "%n=== Top Region ===%n  %s with $%,.2f total revenue%n",
                e.getKey(), e.getValue()));

        // ===== 8. Summary Statistics per Category =====
        System.out.println("\n=== Revenue Stats per Category ===");
        sales.stream()
            .collect(Collectors.groupingBy(
                Sale::category,
                Collectors.summarizingDouble(s -> s.amount() * s.quantity())
            ))
            .forEach((cat, stats) -> System.out.printf(
                "  %s: %d transactions, avg=$%,.2f, min=$%,.2f, max=$%,.2f, total=$%,.2f%n",
                cat, stats.getCount(), stats.getAverage(),
                stats.getMin(), stats.getMax(), stats.getSum()));

        // ===== 9. Category-Region Cross Report =====
        System.out.println("\n=== Category-Region Revenue Matrix ===");
        Map<String, Map<String, Double>> matrix = sales.stream()
            .collect(Collectors.groupingBy(
                Sale::category,
                Collectors.groupingBy(
                    Sale::region,
                    Collectors.summingDouble(s -> s.amount() * s.quantity())
                )
            ));
        matrix.forEach((cat, regions) -> {
            System.out.println("  " + cat + ":");
            regions.entrySet().stream()
                .sorted(Map.Entry.<String, Double>comparingByValue().reversed())
                .forEach(e -> System.out.printf("    %-10s $%,10.2f%n", e.getKey(), e.getValue()));
        });

        // ===== 10. Full Report as Formatted String =====
        System.out.println("\n=== Full Sales Report ===");
        String report = sales.stream()
            .sorted(Comparator.comparing(Sale::date))
            .map(s -> String.format("  %s | %-10s | %-12s | %-6s | %3d units | $%,10.2f",
                s.date(), s.product(), s.category(), s.region(),
                s.quantity(), s.amount() * s.quantity()))
            .collect(Collectors.joining("\n"));
        System.out.println(String.format("  %-10s | %-10s | %-12s | %-6s | %9s | %12s",
            "Date", "Product", "Category", "Region", "Qty", "Revenue"));
        System.out.println("  " + "-".repeat(75));
        System.out.println(report);
    }
}

Collectors Quick Reference

Collector Use Case Example
toList() Collect to mutable List stream.collect(Collectors.toList())
toSet() Collect to Set (no duplicates) stream.collect(Collectors.toSet())
toMap() Build key-value pairs Collectors.toMap(k, v, merge)
joining() Concatenate strings Collectors.joining(", ", "[", "]")
groupingBy() Group by classifier Collectors.groupingBy(f, downstream)
partitioningBy() Split into true/false Collectors.partitioningBy(pred)
counting() Count elements in group groupingBy(f, counting())
summingDouble() Sum numeric values groupingBy(f, summingDouble(g))
averagingDouble() Average numeric values groupingBy(f, averagingDouble(g))
summarizingDouble() All stats in one pass groupingBy(f, summarizingDouble(g))
minBy() / maxBy() Min/max in group groupingBy(f, minBy(comp))
reducing() Custom reduction per group groupingBy(f, reducing(id, mapper, op))
mapping() Transform before downstream groupingBy(f, mapping(g, toList()))
flatMapping() Flatten before downstream groupingBy(f, flatMapping(g, toSet()))
filtering() Filter within each group groupingBy(f, filtering(pred, toList()))
Collector.of() Build a custom collector Collector.of(supplier, acc, comb, fin)

Key Takeaways

  • Collectors are the bridge between streams and concrete data structures. The collect() terminal operation relies on them entirely.
  • toList(), toSet(), toMap() cover 80% of use cases. Learn these first.
  • groupingBy() is the Swiss army knife for analytics. Combined with downstream collectors (counting, summing, averaging, mapping), it replaces complex manual grouping code.
  • partitioningBy() is a specialized groupingBy for boolean splits — always has both true and false keys.
  • Downstream collectors (counting, mapping, reducing, filtering, flatMapping) compose inside groupingBy to build powerful data transformations.
  • toMap() merge functions handle duplicate keys gracefully — decide whether to keep first, keep last, or combine.
  • Custom collectors via Collector.of() give you full control when built-in options are insufficient.
  • summarizingDouble() is efficient — it computes count, sum, min, max, and average in a single pass.