Advanced Java Stream Collectors: groupingBy, teeing, and Custom Collectors

Most Java developers know toList() and joining(), but the Collectors class has over 40 factory methods covering grouping, partitioning, summarizing, mapping, and more. Mastering downstream collectors, teeing, and custom Collector.of() unlocks powerful single-pass data transformations that would otherwise require multiple iterations or complex loops.

Contents

groupingBy and partitioningBy

groupingBy() is a terminal collector that groups stream elements into a Map where each key is produced by a classifier function applied to each element, and each value is the list (or other downstream collection) of matching elements. partitioningBy() is a special case that splits elements into exactly two groups under the keys true and false using a predicate; both collectors accept an optional downstream collector as a second argument for further reduction of each group.

Java
import java.util.stream.*;
import java.util.*;

record Order(String customer, String category, double amount) {}

List<Order> orders = List.of(
    new Order("Alice", "Books",    29.99),
    new Order("Bob",   "Books",    14.99),
    new Order("Alice", "Electronics", 299.99),
    new Order("Bob",   "Electronics", 499.99),
    new Order("Alice", "Books",    9.99)
);

// groupingBy — group by a classifier function
Map<String, List<Order>> byCustomer =
    orders.stream().collect(Collectors.groupingBy(Order::customer));
// {Alice=[...], Bob=[...]}

// groupingBy — group by multiple fields (cascade)
Map<String, Map<String, List<Order>>> byCustomerAndCategory =
    orders.stream().collect(
        Collectors.groupingBy(Order::customer,
            Collectors.groupingBy(Order::category)));

// partitioningBy — splits into exactly two groups: true and false
Map<Boolean, List<Order>> highLow = orders.stream().collect(
    Collectors.partitioningBy(o -> o.amount() > 100));
List<Order> highValue = highLow.get(true);   // amount > 100
List<Order> lowValue  = highLow.get(false);

// Counting in groups
Map<String, Long> countByCategory = orders.stream().collect(
    Collectors.groupingBy(Order::category, Collectors.counting()));
// {Books=3, Electronics=2}

Downstream Collectors

Downstream collectors transform the grouped collections. They are the second argument to groupingBy and partitioningBy:

Java
// Sum amounts per customer
Map<String, Double> totalByCustomer = orders.stream().collect(
    Collectors.groupingBy(Order::customer,
        Collectors.summingDouble(Order::amount)));
// {Alice=339.97, Bob=514.98}

// Average per category
Map<String, Double> avgByCategory = orders.stream().collect(
    Collectors.groupingBy(Order::category,
        Collectors.averagingDouble(Order::amount)));

// Collect to a specific collection type
Map<String, TreeSet<Double>> sortedAmounts = orders.stream().collect(
    Collectors.groupingBy(Order::category,
        Collectors.mapping(Order::amount,
            Collectors.toCollection(TreeSet::new))));

// summarizingDouble — count, sum, min, max, avg in one pass
Map<String, DoubleSummaryStatistics> stats = orders.stream().collect(
    Collectors.groupingBy(Order::category,
        Collectors.summarizingDouble(Order::amount)));
stats.forEach((cat, s) ->
    System.out.printf("%s: count=%d sum=%.2f avg=%.2f%n",
        cat, s.getCount(), s.getSum(), s.getAverage()));

// joining — concatenate strings
Map<String, String> customerOrders = orders.stream().collect(
    Collectors.groupingBy(Order::customer,
        Collectors.mapping(Order::category,
            Collectors.joining(", ", "[", "]"))));
// {Alice=[Books, Electronics, Books], Bob=[Books, Electronics]}

// maxBy / minBy
Map<String, Optional<Order>> biggestByCustomer = orders.stream().collect(
    Collectors.groupingBy(Order::customer,
        Collectors.maxBy(Comparator.comparingDouble(Order::amount))));

teeing: Two Collectors in One Pass

Collectors.teeing(), added in Java 12, applies two collectors to the same stream simultaneously and merges their results with a BiFunction:

Java
import java.util.stream.Collectors;

// Find min and max in a single pass
record MinMax(int min, int max) {}
MinMax minMax = IntStream.rangeClosed(1, 10).boxed().collect(
    Collectors.teeing(
        Collectors.minBy(Comparator.naturalOrder()),
        Collectors.maxBy(Comparator.naturalOrder()),
        (min, max) -> new MinMax(min.orElseThrow(), max.orElseThrow())
    ));
System.out.println(minMax); // MinMax[min=1, max=10]

// Count and sum simultaneously
record CountSum(long count, double sum) {}
CountSum cs = orders.stream().collect(
    Collectors.teeing(
        Collectors.counting(),
        Collectors.summingDouble(Order::amount),
        CountSum::new
    ));
System.out.printf("count=%d, sum=%.2f%n", cs.count(), cs.sum());

// Partition and count each partition
record Split(List<Order> high, List<Order> low) {}
Split split = orders.stream().collect(
    Collectors.teeing(
        Collectors.filtering(o -> o.amount() > 100, Collectors.toList()),
        Collectors.filtering(o -> o.amount() <= 100, Collectors.toList()),
        Split::new
    ));

// Average and standard deviation in one pass
double[] avgAndStdDev = orders.stream()
    .mapToDouble(Order::amount)
    .collect(
        () -> new double[3],                    // [sum, sumSq, count]
        (arr, v) -> { arr[0]+=v; arr[1]+=v*v; arr[2]++; },
        (a, b)   -> { a[0]+=b[0]; a[1]+=b[1]; a[2]+=b[2]; }
    );
// Manual aggregation alternative to teeing for primitive streams

flatMapping and filtering Collectors

Collectors.flatMapping() and Collectors.filtering(), added in Java 9, are especially useful as downstream collectors:

Java
record BlogPost(String author, List<String> tags) {}

List<BlogPost> posts = List.of(
    new BlogPost("Alice", List.of("java", "streams", "functional")),
    new BlogPost("Bob",   List.of("java", "concurrency")),
    new BlogPost("Alice", List.of("kotlin", "java"))
);

// flatMapping — flatten lists and collect per group
Map<String, List<String>> tagsByAuthor = posts.stream().collect(
    Collectors.groupingBy(BlogPost::author,
        Collectors.flatMapping(p -> p.tags().stream(),
            Collectors.toList())));
// {Alice=[java, streams, functional, kotlin, java], Bob=[java, concurrency]}

// flatMapping with distinct
Map<String, Set<String>> uniqueTagsByAuthor = posts.stream().collect(
    Collectors.groupingBy(BlogPost::author,
        Collectors.flatMapping(p -> p.tags().stream(),
            Collectors.toUnmodifiableSet())));

// filtering — filter elements within each group
Map<String, List<Order>> largeOrdersByCustomer = orders.stream().collect(
    Collectors.groupingBy(Order::customer,
        Collectors.filtering(o -> o.amount() > 100,
            Collectors.toList())));
// Includes keys even when no elements pass the filter (unlike stream().filter())
// {Alice=[Order(Alice, Electronics, 299.99)], Bob=[Order(Bob, Electronics, 499.99)]}

// Compare: filtering collector keeps empty groups, stream filter does not
Map<String, Long> countHighOrders = orders.stream().collect(
    Collectors.groupingBy(Order::customer,
        Collectors.filtering(o -> o.amount() > 1000, Collectors.counting())));
// {Alice=0, Bob=0} — both customers have zero high-value orders, but both keys present

Custom Collectors with Collector.of()

Build fully custom collectors using Collector.of() by providing supplier, accumulator, combiner, and finisher functions:

Java
// Custom collector: collect to an immutable LinkedList (in order)
Collector<String, ArrayDeque<String>, List<String>> toImmutableList =
    Collector.of(
        ArrayDeque::new,            // supplier: create mutable container
        ArrayDeque::addLast,        // accumulator: add element
        (a, b) -> { a.addAll(b); return a; }, // combiner: merge two containers (parallel)
        List::copyOf                // finisher: convert to immutable list
    );

List<String> result = Stream.of("a", "b", "c").collect(toImmutableList);
System.out.println(result); // [a, b, c]

// Custom collector: build a frequency map
Collector<String, Map<String, Integer>, Map<String, Integer>> freqMap =
    Collector.of(
        HashMap::new,
        (map, s) -> map.merge(s, 1, Integer::sum),
        (a, b) -> { b.forEach((k, v) -> a.merge(k, v, Integer::sum)); return a; },
        Collections::unmodifiableMap
    );

Map<String, Integer> freq = Stream.of("a","b","a","c","b","a").collect(freqMap);
System.out.println(freq); // {a=3, b=2, c=1}

// Custom collector: running statistics (mean, variance online)
Collector<Double, double[], double[]> stats = Collector.of(
    () -> new double[3],  // [count, mean, M2] — Welford's online algorithm
    (arr, x) -> {
        arr[0]++;
        double delta = x - arr[1];
        arr[1] += delta / arr[0];
        arr[2] += delta * (x - arr[1]);
    },
    (a, b) -> { /* combine — complex for Welford's, omitted */ return a; },
    arr -> arr  // finisher — caller reads arr[0]=n, arr[1]=mean, arr[2]=M2
);
Use Collector.Characteristics.UNORDERED and IDENTITY_FINISH flags in Collector.of() for better parallel performance when your collector doesn't care about element order and the mutable container is the final result.