Stream Collectors Deep Dive
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.
Learn Stream Collectors Deep Dive in our free Java course — a beginner-friendly interactive lesson with worked examples, a practice exercise and a quick…
Part of the free Java course at LearnCodingFast — hands-on lessons with examples you run in your browser, 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
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.
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.
You can even pass a third argument — a map supplier like TreeMap::new — to control the map type and key ordering.
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.
🧠 Quick Recall
Predict the result before opening each answer.
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.
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.
🧩 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 .
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
❓ Frequently Asked Questions
🎉 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.
Next up: Virtual Threads (Java 21) — lightweight threads that let you run millions of blocking tasks at once.
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?
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 (Java 21)