Stream Collectors Deep Dive
Reviewed & published by Brayan K
A Collector is a reusable recipe that tells stream.collect() how to fold a stream of elements into a final result — a list, a set, a map, a number, or a joined string.
Part of the free Java course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
Before You Start
You should be comfortable with the Streams API (map, filter) and with lambdas and method references, since every collector is configured with them. A passing acquaintance with records helps too — we use them for sample data.
What You'll Learn
- toList, toSet and toMap — and toMap's duplicate-key trap
- groupingBy, the workhorse of categorisation
- Downstream collectors: counting, summingInt, mapping
- partitioningBy for clean true/false splits
- joining for building delimited strings
- teeing and collectingAndThen for advanced pipelines
The Big Idea — A Sorting Office
💡 Analogy: A stream is a conveyor belt of letters flowing past. A Collector is the clerk at the end of the belt with instructions on what to do with them. One clerk drops every letter into a single sack (toList). Another has a row of pigeonholes labelled by city and files each letter into the matching slot (groupingBy). A third just keeps a running tally on a clipboard (counting). The belt is the same — swapping the clerk changes the whole outcome.
That is why collectors are so powerful: the shape of your result lives in one composable object you pass to collect(), not in a tangle of loops and temporary variables.
1️⃣ toList, toSet, toMap
The three you'll reach for most. toList() keeps order and duplicates; toSet() drops duplicates and has no guaranteed order; toMap(keyFn, valueFn) builds a key→value map but throws if two elements share a key.
import java.util.*;
import java.util.stream.*;
public class Main {
public static void main(String[] args) {
List<String> words = List.of("apple", "banana", "cherry", "avocado", "blueberry");
// toList — collect into a List
List<String> upper = words.stream()
.map(String::toUpperCase)
.collect(Collectors.toList());
System.out.println("toList: " + upper);
// toSet — collect into a Set (duplicates removed)
Set<Integer> lengths = words.stream()
.map(String::length)
.collect(Collectors.toSet());
System.out.println("toSet: " + new TreeSet<>(lengths));
// toMap — word -> its length
Map<String, Integer> lenByWord = words.stream()
.collect(Collectors.toMap(w -> w, String::length));
System.out.println("toMap: " + new TreeMap<>(lenByWord));
}
}2️⃣ groupingBy & Downstream Collectors
groupingBy is the single most useful collector. Its first argument — the classifier — produces the key for each element. With just that, each key maps to a List of its members. The magic is the optional second argument: a downstream collector that reduces each group however you like.
groupingBy(Person::city) // Map<String, List<Person>>
groupingBy(Person::city, counting()) // Map<String, Long>
groupingBy(Person::city, summingInt(Person::age)) // Map<String, Integer>
groupingBy(Person::city, mapping(Person::name, toList())) // Map<String, List<String>>You can even pass a third argument — a map supplier like TreeMap::new — to control the map type and key ordering.
import java.util.*;
import java.util.stream.*;
public class Main {
record Person(String name, String city, int age) {}
public static void main(String[] args) {
List<Person> people = List.of(
new Person("Ana", "Paris", 30),
new Person("Ben", "Paris", 25),
new Person("Cara", "Lyon", 40),
new Person("Dan", "Lyon", 35),
new Person("Eve", "Paris", 28));
// groupingBy + mapping — collect just the names per city
Map<String, List<String>> byCity = people.stream()
.collect(Collectors.groupingBy(Person::city,
Collectors.mapping(Person::name, Collectors.toList())));
System.out.println("names by city: " + new TreeMap<>(byCity));
// groupingBy + counting — how many per city
Map<String, Long> countByCity = people.stream()
.collect(Collectors.groupingBy(Person::city, Collectors.counting()));
System.out.println("count by city: " + new TreeMap<>(countByCity));
// groupingBy + summingInt — total age per city
Map<String, Integer> ageSum = people.stream()
.collect(Collectors.groupingBy(Person::city, Collectors.summingInt(Person::age)));
System.out.println("age sum: " + new TreeMap<>(ageSum));
// groupingBy + averagingInt — average age per city
Map<String, Double> ageAvg = people.stream()
.collect(Collectors.groupingBy(Person::city, Collectors.averagingInt(Person::age)));
System.out.println("age avg: " + new TreeMap<>(ageAvg));
}
}3️⃣ partitioningBy, joining, teeing, collectingAndThen
- partitioningBy(predicate) — a special two-bucket grouping; the returned map always has both the true and false keys.
- joining(delimiter, prefix, suffix) — concatenates a stream of strings into one string with separators.
- teeing(c1, c2, merger) — feeds every element to two collectors at once, then combines their results (great for "sum and count in one pass").
- collectingAndThen(collector, finisher) — runs a collector, then applies a final transformation, e.g. wrapping the result so it's unmodifiable.
import java.util.*;
import java.util.stream.*;
public class Main {
public static void main(String[] args) {
List<Integer> nums = List.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
// partitioningBy — split into true/false buckets by a predicate
Map<Boolean, List<Integer>> parts = nums.stream()
.collect(Collectors.partitioningBy(n -> n % 2 == 0));
System.out.println("evens: " + parts.get(true));
System.out.println("odds: " + parts.get(false));
// joining — concatenate strings with delimiter, prefix, suffix
String csv = nums.stream()
.map(String::valueOf)
.collect(Collectors.joining(", ", "[", "]"));
System.out.println("joined: " + csv);
// teeing — run TWO collectors and merge their results (Java 12+)
double average = nums.stream()
.collect(Collectors.teeing(
Collectors.summingInt(Integer::intValue),
Collectors.counting(),
(sum, count) -> (double) sum / count));
System.out.println("teeing average: " + average);
// collectingAndThen — collect, then post-process (make unmodifiable)
List<Integer> locked = nums.stream()
.filter(n -> n > 7)
.collect(Collectors.collectingAndThen(
Collectors.toList(),
Collections::unmodifiableList));
System.out.println("locked: " + locked);
try {
locked.add(99);
} catch (UnsupportedOperationException e) {
System.out.println("add rejected: list is unmodifiable");
}
}
}🧠 Quick Recall
Predict the result before opening each answer.
Stream.of("a","bb","cc","d")
.collect(Collectors.groupingBy(String::length, Collectors.counting()));Answer: a Map<Integer, Long>, specifically — two strings of length 1 and two of length 2. The downstream counting() turns each group into a Long tally.
Stream.of("ab","cd","ef")
.collect(Collectors.toMap(String::length, s -> s));Answer: all three strings have length 2, so the key 2 collides and toMap throws IllegalStateException: Duplicate key. Add a merge function like (a, b) -> a to keep the first.
Answer: the result map always contains both the true and false keys, even if one of the lists is empty. With groupingBy a key only appears if at least one element matched it.
🎯 YOUR TURN — Group by length
Use groupingBy(String::length) to bucket the languages, then print a TreeMap so keys are ordered.
import java.util.*;
import java.util.stream.*;
public class Main {
public static void main(String[] args) {
List<String> langs = List.of("Java", "Go", "Rust", "C", "Kotlin", "PHP");
// YOUR TURN: group the languages BY THEIR LENGTH.
// Use Collectors.groupingBy(String::length) and print a TreeMap so the
// keys come out in order.
//
// Expected output:
// {1=[C], 2=[Go], 3=[PHP], 4=[Java, Rust], 6=[Kotlin]}
// Map<Integer, List<String>> byLen = ...
// System.out.println(new TreeMap<>(byLen));
}
}🧩 MINI-CHALLENGE — Spend per customer
Aggregate orders into a sorted total-spend map, then find the top spender. Combine groupingBy, a TreeMap supplier, and summingDouble.
import java.util.*;
import java.util.stream.*;
public class Main {
record Order(String customer, double total) {}
public static void main(String[] args) {
List<Order> orders = List.of(
new Order("Ana", 120.0),
new Order("Ben", 50.0),
new Order("Ana", 30.0),
new Order("Cara", 200.0),
new Order("Ben", 70.0));
// MINI-CHALLENGE:
// 1. Total spend per customer, in sorted (TreeMap) order, printed as
// "name: total". Use groupingBy with a TreeMap supplier and
// summingDouble.
// 2. Find the single top-spending customer.
//
// Expected output:
// Ana: 150.0
// Ben: 120.0
// Cara: 200.0
// Top spender: Cara
// your code here
}
}Common Errors
- ❌ toMap with colliding keys. IllegalStateException: Duplicate key. Fix: add a merge function, e.g. toMap(k, v, (a, b) -> a), or switch to groupingBy.
- ❌ Relying on HashMap iteration order. The printed order is undefined. Fix: collect into a TreeMap (via a third groupingBy argument) or sort afterwards.
- ❌ Mutating the result of toList() / collect you didn't create. Stream.toList() returns an unmodifiable list. Fix: use collect(Collectors.toCollection(ArrayList::new)) when you need to mutate.
- ❌ Forgetting the downstream collector. groupingBy(Person::city) gives you Lists when you wanted counts. Fix: pass counting(), summingInt(...), etc.
- ❌ Using reduce where a collector is clearer. Hand-rolled reductions are easy to get wrong. Fix: prefer the named Collectors factory; it reads better and parallelises safely.
Pro Tips
- 💡 Static-import Collectors.* in stream-heavy files so you can write groupingBy(...) instead of Collectors.groupingBy(...).
- 💡 Nest groupingBy for multi-level grouping: groupingBy(Person::city, groupingBy(Person::age)) gives a map of maps.
- 💡 Prefer summarizingInt when you need count, sum, min, max and average together — it returns an IntSummaryStatistics in one pass.
- 💡 collectingAndThen + unmodifiableList is the canonical way to return a defensive, read-only collection from a method.
📋 Quick Reference
| Concept | Syntax | Note |
|---|---|---|
| toList | collect(toList()) | Keeps order & dupes |
| toSet | collect(toSet()) | Drops dupes, no order |
| toMap | toMap(k, v[, merge]) | Add merge fn for dupe keys |
| groupingBy | groupingBy(classifier) | Map<K, List<T>> |
| + counting | groupingBy(c, counting()) | Map<K, Long> |
| + summingInt | summingInt(T::n) | Total per group |
| + mapping | mapping(T::f, toList()) | Extract one field |
| partitioningBy | partitioningBy(pred) | Always true & false keys |
| joining | joining(", ", "[", "]") | Strings only |
| teeing | teeing(c1, c2, merge) | Two collectors, one pass (Java 12+) |
| collectingAndThen | collectingAndThen(c, fn) | Post-process the result |
| summarizingInt | summarizingInt(T::n) | count/sum/min/max/avg |
📚 Resources / Keep Learning
- 📘 Collectors — Java 21 API documentation (docs.oracle.com)
- 📘 Stream interface — Java 21 API documentation (docs.oracle.com)
- 📗 Collecting Stream Elements into Collections — dev.java tutorial
🎉 Lesson Complete!
You can now reach for the right collector instead of writing loops: toList/toSet/toMap for shape, groupingBy with downstreams for aggregation, and partitioningBy, joining, teeing and collectingAndThen for the trickier cases.
Practice quiz
Which collector turns a stream into a List?
- Collectors.toList()
- Collectors.listOf()
- Collectors.asList()
- Collectors.collectList()
Answer: Collectors.toList(). Collectors.toList() accumulates elements into a List. (Stream.toList() is an even shorter Java 16+ shortcut.)
What does Collectors.groupingBy(Person::city) return?
- A Map<String, Person>
- A Map<String, List<Person>>
- A List<Person>
- A Set<String>
Answer: A Map<String, List<Person>>. groupingBy with one argument maps each key to a List of the elements that share it.
Which collector splits a stream into exactly two buckets keyed by true/false?
- groupingBy
- partitioningBy
- teeing
- mapping
Answer: partitioningBy. partitioningBy(predicate) returns a Map<Boolean, List<T>> with exactly the true and false keys.
What is the role of the downstream collector in groupingBy(classifier, downstream)?
- It sorts the keys
- It decides how to reduce the elements in each group
- It removes duplicate keys
- It limits the stream size
Answer: It decides how to reduce the elements in each group. The downstream collector (counting, mapping, summingInt, ...) reduces each group's elements instead of leaving them as a List.
Which collector concatenates strings with a delimiter, prefix, and suffix?
- Collectors.joining(", ", "[", "]")
- Collectors.concat()
- Collectors.append()
- Collectors.mkString()
Answer: Collectors.joining(", ", "[", "]"). joining(delimiter, prefix, suffix) builds one String like [a, b, c].
Collectors.teeing(c1, c2, merger) does what?
- Runs two collectors over the same stream and merges their results
- Splits the stream in half
- Tees output to a file
- Runs a collector twice
Answer: Runs two collectors over the same stream and merges their results. teeing feeds every element to both downstream collectors, then combines their two results with the merge function.
Why might toMap throw IllegalStateException?
- The stream is empty
- Two elements produce the same key
- The values are null
- The map is unmodifiable
Answer: Two elements produce the same key. Without a merge function, duplicate keys cause 'Duplicate key' IllegalStateException. Supply a third merge argument to resolve collisions.
What does Collectors.counting() produce as a value?
- An int
- A Long
- A Double
- A String
Answer: A Long. counting() returns a Long count of elements in each group.
collectingAndThen(toList(), Collections::unmodifiableList) is used to...
- Sort the list
- Collect then post-process the result (here, make it read-only)
- Count the elements
- Reverse the list
Answer: Collect then post-process the result (here, make it read-only). collectingAndThen applies a finishing function to the collected result — a common idiom for returning an unmodifiable collection.
Which downstream collector keeps only one field of each grouped element?
- Collectors.mapping(Person::name, toList())
- Collectors.reducing()
- Collectors.filtering()
- Collectors.flatMapping()
Answer: Collectors.mapping(Person::name, toList()). mapping(extractor, downstream) transforms each element before the inner collector accumulates it.
Continue this course
- Previous: Checkpoint: Modern Java
- Next: Virtual Threads — Lightweight threads for massive concurrency with Thread.ofVirtual (Java 21)
- Quick reference: Java cheat sheet › Streams
Frequently asked questions
What is a Collector in Java streams?
A Collector is a recipe for accumulating the elements of a stream into a final result — a List, a Set, a Map, a single number, a String, or anything you can fold values into. You hand one to the terminal operation stream.collect(...). The Collectors utility class holds dozens of ready-made factories such as toList(), groupingBy(), and joining(), so you rarely write a Collector from scratch.
What is the difference between groupingBy and partitioningBy?
Both bucket a stream, but partitioningBy always produces exactly two buckets keyed by the boolean true and false from a predicate, and both keys are always present (even if one bucket is empty). groupingBy produces one bucket per distinct key your classifier returns — any number of them — and absent keys simply don't appear. Use partitioningBy for a yes/no split and groupingBy for an open-ended categorisation.
What is a downstream collector?
When you call groupingBy(classifier, downstream), the second argument decides how each group's elements are reduced. With no downstream you get a List per group. With Collectors.counting() you get a Long count, with summingInt(...) a total, with mapping(extractor, toList()) a List of one extracted field, and so on. Downstream collectors are the key to expressive one-line aggregations.
Why does Collectors.toMap throw a 'Duplicate key' exception?
The two-argument toMap(keyFn, valueFn) assumes every key is unique. If two stream elements map to the same key it cannot decide which value wins, so it throws IllegalStateException: Duplicate key. Supply a third argument — a merge function such as (a, b) -> a or Integer::sum — to tell it how to combine colliding values, or use groupingBy when collisions are expected.
Should I use Stream.toList() or Collectors.toList()?
Since Java 16, stream.toList() is a concise terminal operation that returns an unmodifiable List, and it's the better default when you just want a list. Collectors.toList() still matters when you need it as a downstream collector inside groupingBy or mapping, or when you specifically need a mutable ArrayList (the Collectors version historically returns a mutable list, though that is not guaranteed by the spec).