The Collectors.teeing() method returns a collector that sends every stream element to two downstream collectors and merges their two results with a BiFunction. It lets us compute two different results, such as a sum and a count, in a single pass over the stream.
We use teeing() when a report needs two values from the same data, for example the cheapest and the most expensive product, or the matching orders and their total. Without it, we either stream the list twice or write a custom collector.
The following example sums the durations of four songs and counts them in one collect() call.
List<Integer> songSeconds = List.of(215, 180, 240, 270);
Collector<Integer, ?, String> totalAndCount = Collectors.teeing(
Collectors.summingInt(Integer::intValue), // 905
Collectors.counting(), // 4
(total, count) -> total + "s in " + count + " songs");
String summary = songSeconds.stream().collect(totalAndCount); // "905s in 4 songs"
Notice that teeing() returns an ordinary Collector, and its third argument receives the results of both downstream collectors and builds the final value. In the next sections we look at the method signature, the classic min and max, filter and count, and average and sum examples, returning a record instead of a Map, empty and parallel streams, and how teeing() compares with other ways to get two results.
1. Purpose of the teeing() Collector
A collector reduces a stream to one result. Some tasks need two results from the same elements, and before Java 12 we had two choices. We could run two stream pipelines over the same list, or we could write one collector by hand that keeps two accumulators.
The teeing() collector processes the stream once and feeds each element to both downstream collectors, so we reuse existing collectors such as counting() or maxBy() instead of writing new ones. When the stream ends, it finishes both collectors and passes their results to the merger function.

The name comes from a T-shaped pipe fitting, and from the Unix tee command, which copies its input to two outputs. The processing is still sequential on a sequential stream. The method does not run the two collectors in separate threads.
2. The teeing() Method Signature
The method is static and generic, which is why its signature looks long. It has four type parameters, and the compiler infers all of them from the arguments.
// signature only, declared in java.util.stream.Collectors (does not compile on its own)
public static <T, R1, R2, R> Collector<T, ?, R> teeing(
Collector<? super T, ?, R1> downstream1,
Collector<? super T, ?, R2> downstream2,
BiFunction<? super R1, ? super R2, R> merger)
Each type parameter describes one part of the data flow, from the stream elements to the final result.
| Parameter | Meaning | In the intro example |
|---|---|---|
| T | Type of the stream elements | Integer |
| downstream1 | First collector, produces an R1 | summingInt(), so R1 is Integer |
| downstream2 | Second collector, produces an R2 | counting(), so R2 is Long |
| merger | BiFunction that turns R1 and R2 into R | a lambda that builds a String |
All three arguments are required. Passing null for any of them throws a NullPointerException when teeing() is called, before the stream runs.
3. Using teeing() to Find Max and Min
The next examples use a small list of employees. The record Employee holds a name and a salary, and a second record SalaryRange holds the two employees we want to find.
record Employee(String name, double salary) {}
record SalaryRange(Employee lowest, Employee highest) {}
We pass minBy() and maxBy() as the two downstream collectors. Both return an Optional, because an empty stream has no minimum or maximum. The merger combines the two Optional values, so the result is an empty Optional for an empty list instead of an exception.
List<Employee> employees = List.of(
new Employee("Ana", 100),
new Employee("Ben", 200),
new Employee("Chris", 300),
new Employee("Dana", 400));
Comparator<Employee> bySalary = Comparator.comparingDouble(Employee::salary);
Optional<SalaryRange> range = employees.stream()
.collect(Collectors.teeing(
Collectors.minBy(bySalary),
Collectors.maxBy(bySalary),
(min, max) -> min.flatMap(lo -> max.map(hi -> new SalaryRange(lo, hi)))));
String lowest = range.map(r -> r.lowest().name()).orElse("none"); // "Ana"
String highest = range.map(r -> r.highest().name()).orElse("none"); // "Dana"
Calling min.get() and max.get() inside the merger also works for this list, but it throws a NoSuchElementException when the list is empty. Mapping the two Optional values keeps the code safe for any input.
4. Using teeing() to Filter and Count
The second example finds the employees who earn more than 200, together with how many there are. The first downstream collector is filtering() with toList(), and the second one is filtering() with counting(), both with the same condition.
List<Employee> employees = List.of(
new Employee("Ana", 100),
new Employee("Ben", 200),
new Employee("Chris", 300),
new Employee("Dana", 400));
Predicate<Employee> wellPaid = e -> e.salary() > 200;
Map<String, Object> result = employees.stream()
.collect(Collectors.teeing(
Collectors.filtering(wellPaid, Collectors.toList()),
Collectors.filtering(wellPaid, Collectors.counting()),
(list, count) -> Map.of("list", list, "count", count)));
Object count = result.get("count"); // 2
Object names = result.get("list"); // [Employee[name=Chris, salary=300.0], Employee[name=Dana, salary=400.0]]
A Map<String, Object> works, but the caller has to cast each value. When both results come from the same filtered elements, it is simpler to filter first and let teeing() combine the list with a value that needs a second collector, such as the salary total.
List<Employee> employees = List.of(
new Employee("Ana", 100),
new Employee("Ben", 200),
new Employee("Chris", 300),
new Employee("Dana", 400));
record PaidGroup(List<String> names, double total) {}
PaidGroup group = employees.stream()
.filter(e -> e.salary() > 200)
.collect(Collectors.teeing(
Collectors.mapping(Employee::name, Collectors.toList()),
Collectors.summingDouble(Employee::salary),
PaidGroup::new));
List<String> groupNames = group.names(); // [Chris, Dana]
double groupTotal = group.total(); // 700.0
Notice that the merger is the record constructor PaidGroup::new. A method reference works whenever a constructor or a static method takes the two results in the same order.
5. Using teeing() to Average and Sum
For numbers, teeing() pairs two numeric collectors. The example calculates the average and the sum of the numbers from 1 to 10 and formats both in one string.
List<Integer> numbers = IntStream.rangeClosed(1, 10).boxed().toList();
String stats = numbers.stream()
.collect(Collectors.teeing(
Collectors.averagingInt(Integer::intValue),
Collectors.summingInt(Integer::intValue),
(average, sum) -> String.format("Average: %.2f, Sum: %d", average, sum)));
String printed = stats; // "Average: 5.50, Sum: 55"
The format pattern %.2f uses the default locale, so a German system prints 5,50. For output that other programs read, we pass Locale.ROOT to String.format().
6. Combining More Than Two Collectors
The method accepts two downstream collectors, no more and no fewer. For three results, we nest a second teeing() call as one of the downstream collectors. The inner call produces a value that the outer merger receives like any other result.
record Totals(long count, double sum, double max) {}
List<Employee> employees = List.of(
new Employee("Ana", 100),
new Employee("Ben", 200),
new Employee("Chris", 300),
new Employee("Dana", 400));
Totals totals = employees.stream()
.collect(Collectors.teeing(
Collectors.counting(),
Collectors.teeing(
Collectors.summingDouble(Employee::salary),
Collectors.mapping(Employee::salary, Collectors.maxBy(Double::compare)),
(sum, max) -> new double[] {sum, max.orElse(0.0)}),
(count, pair) -> new Totals(count, pair[0], pair[1])));
String report = totals.toString(); // "Totals[count=4, sum=1000.0, max=400.0]"
Nesting gets hard to read after two levels. When we need many numeric values of one field, summarizingDouble() is simpler, as the comparison in section 8 shows.
7. Empty and Parallel Streams
On an empty stream, teeing() still calls the merger once, with the results that each downstream collector returns for no elements. That is 0 for counting() and summingInt(), 0.0 for averagingInt() and an empty Optional for minBy() and maxBy().
List<Integer> none = List.of();
String emptyStats = none.stream()
.collect(Collectors.teeing(
Collectors.averagingInt(Integer::intValue),
Collectors.counting(),
(avg, count) -> avg + " / " + count));
String emptyShown = emptyStats; // "0.0 / 0"
The collector also works on parallel streams. Each thread fills its own pair of containers, and teeing() combines them by calling the combiner of each downstream collector. The result is the same as on a sequential stream.
List<Integer> thousand = IntStream.rangeClosed(1, 1_000).boxed().toList();
Collector<Integer, ?, String> countAndSum = Collectors.teeing(
Collectors.counting(),
Collectors.summingLong(Integer::longValue),
(count, sum) -> count + " / " + sum);
String parallelResult = thousand.parallelStream().collect(countAndSum); // "1000 / 500500"
The resulting collector is UNORDERED only if both downstream collectors are unordered, and CONCURRENT only if both are concurrent. In the JDK implementation, it never has the IDENTITY_FINISH characteristic, because the merger always runs at the end.
8. teeing() Compared With Other Approaches
The teeing() method is not the only way to get two results. Picking the right tool depends on whether the two results describe the same field, two groups or two different calculations.
| Need | Better choice | Why |
|---|---|---|
| Count, sum, min, max and average of one number | summarizingInt() or IntStream.summaryStatistics() | One call returns all five values |
| Split elements into two groups by a condition | partitioningBy() | Returns a Map<Boolean, List> with both groups |
| Group by a key | groupingBy() | One entry per key |
| Two different calculations, such as a list and a total | teeing() | Reuses two existing collectors in one pass |
| Two results from a list that is already in memory | two separate streams | Shorter code, and the cost of a second pass is often small |
List<Employee> employees = List.of(
new Employee("Ana", 100),
new Employee("Ben", 200),
new Employee("Chris", 300),
new Employee("Dana", 400));
DoubleSummaryStatistics salaryStats = employees.stream()
.collect(Collectors.summarizingDouble(Employee::salary));
double avgSalary = salaryStats.getAverage(); // 250.0
double maxSalary = salaryStats.getMax(); // 400.0
The single pass matters most when the source can be read only once, such as lines from a file or rows from a database cursor. For a small List in memory, two plain streams are fine when they read better.
9. A Monthly Order Report With teeing()
Say an online shop sends a monthly email to each customer with the number of delivered orders and the money spent on them. The orders come from a database query as a stream, so we want both values in one pass and in one typed result.
record Order(String status, double amount) {}
record MonthlyReport(long delivered, double spent) {}
List<Order> orders = List.of(
new Order("DELIVERED", 40.0),
new Order("CANCELLED", 15.0),
new Order("DELIVERED", 25.5));
MonthlyReport monthly = orders.stream()
.filter(o -> o.status().equals("DELIVERED"))
.collect(Collectors.teeing(
Collectors.counting(),
Collectors.summingDouble(Order::amount),
MonthlyReport::new));
long deliveredCount = monthly.delivered(); // 2
double spentTotal = monthly.spent(); // 65.5
The record makes the result self-describing, so the email template reads report.spent() instead of a map key. Adding a third value later, such as the largest order, means one nested teeing() and one more record component.
10. Collectors teeing() FAQs
Besides the Java version, most teeing() questions are about speed and about what the merger may return.
10.1. Which Java Version Added Collectors.teeing()?
Java 12 added Collectors.teeing(), and it is a standard method in every later release, including Java 25. The Java version history lists the other stream changes since Java 8.
10.2. Is teeing() Faster Than Two Separate Streams?
Not always. The teeing() collector reads the source once, but each element still goes through both collectors, so the work per element stays about the same. The gain is real when reading the source is expensive or possible only once.
10.3. Can the Merger Return null?
Yes. The merger can return any value, including null, and collect() returns it unchanged. Returning an Optional or a record with clear defaults is easier for callers to handle.
10.4. Can We Use teeing() With groupingBy()?
Yes. A teeing() collector can be the downstream collector of groupingBy(), which gives two results per group, such as the count and the salary total of each group.
List<Employee> employees = List.of(
new Employee("Ana", 100),
new Employee("Ben", 200),
new Employee("Chris", 300),
new Employee("Dana", 400));
Map<Boolean, String> bands = employees.stream()
.collect(Collectors.groupingBy(e -> e.salary() > 200,
Collectors.teeing(
Collectors.counting(),
Collectors.summingDouble(Employee::salary),
(count, total) -> count + " people, " + total)));
String upperBand = bands.get(true); // "2 people, 700.0"
11. Conclusion
The Collectors.teeing() method runs two collectors over the same stream in one pass and merges their results with a BiFunction. It fits tasks such as min and max, a filtered list with its total, or an average with a sum.
We get the cleanest code when the merger builds a record, and the safest code when it handles the empty Optional results of minBy() and maxBy(). For several statistics of one number, summarizingInt() is shorter, and nested teeing() calls cover the rare case of three or more results.
12. References
Happy Learning !!