Java Stream Collectors
Reduce Java streams to collections and other results with java.util.stream.Collectors.
collect is the terminal we deferred. It takes a Collector<T, A, R> — a recipe for accumulating stream elements into a result R through an intermediate container A — and runs it. The recipes live in the java.util.stream.Collectors factory class, and they cover most of what you'd otherwise hand-write with a for loop, a Map, and a few compute* calls. Once you can read groupingBy(..., counting()), the API stops feeling cryptic.
The chapter walks the toolbox by what you want the result to be: a list, a set, a map, a single number, a string, or — via the downstream pattern — a nested combination of any of them.
Lists, sets, and specific collections
The two basic ones:
List<String> list = words.stream().collect(Collectors.toList());
Set<String> set = words.stream().collect(Collectors.toSet());Notes:
Collectors.toList()returns someList— usually mutable, but not guaranteed. For the unmodifiable form you want most of the time, usestream.toList()(the terminal, not the collector).Collectors.toSet()is unordered — typicallyHashSet. If you need stable iteration order, ask for it explicitly withtoCollection(LinkedHashSet::new).Collectors.toUnmodifiableList()andtoUnmodifiableSet()(Java 10+) return immutable results — these are the collector-form equivalents ofstream.toList().
For a specific implementation, use toCollection:
ArrayDeque<String> queue = words.stream()
.collect(Collectors.toCollection(ArrayDeque::new));
TreeSet<String> sorted = words.stream()
.collect(Collectors.toCollection(TreeSet::new));The supplier is a constructor reference; the collector wires it up, drains the stream into it, and returns it.
toMap — pair every element to a key
toMap(keyMapper, valueMapper) turns each element into a Map.Entry and accumulates them:
Map<String, Integer> nameAge = people.stream()
.collect(Collectors.toMap(Person::name, Person::age));Duplicate keys throw IllegalStateException. That's the one rule that catches everyone the first time. If two Persons share a name, the default toMap blows up. The fix is the merge-function overload:
Map<String, Integer> sumAgePerName = people.stream()
.collect(Collectors.toMap(
Person::name,
Person::age,
Integer::sum)); // merge: existingAge + newAgeFor a specific map type — LinkedHashMap to preserve insertion order, TreeMap to keep keys sorted — pass a supplier:
Map<String, Integer> ordered = people.stream()
.collect(Collectors.toMap(
Person::name, Person::age,
(a, b) -> a, // keep first on collision
LinkedHashMap::new));toUnmodifiableMap is the immutable variant (Java 10+).
groupingBy — split into buckets by key
The collector everyone reaches for once they realise toMap isn't the right tool:
Map<String, List<Person>> byRole = people.stream()
.collect(Collectors.groupingBy(Person::role));For each element the classifier produces a key, and the element is added to that key's bucket (default downstream: toList()). Compare to toMap:
| Produces | On duplicate key | |
|---|---|---|
toMap | Map<K, V> (one V per K) | Throws unless you give a merger |
groupingBy | Map<K, List<V>> (a bucket per K) | Adds to the bucket |
Use toMap when there is at most one value per key by design (id → row, code → label). Use groupingBy when there are many.
The full power of groupingBy comes from its downstream parameter, which says what to do with the elements that share a key. The default is toList; you can replace it with another collector — and that collector can itself be a groupingBy. The chapter's "downstream" section below is where the API really opens up.
partitioningBy — bucket by a predicate
A specialised groupingBy for binary predicates. Returns a Map<Boolean, List<T>>:
Map<Boolean, List<Person>> adultsOrNot = people.stream()
.collect(Collectors.partitioningBy(p -> p.age() >= 18));
List<Person> adults = adultsOrNot.get(true);
List<Person> minors = adultsOrNot.get(false);partitioningBy always contains both true and false keys, even if one bucket is empty. That's the one thing it gives you over groupingBy(p -> p.age() >= 18) — which would omit the key if the bucket is empty.
Like groupingBy, partitioningBy accepts a downstream collector.
counting, summingInt, averagingDouble, minBy, maxBy
The downstream collectors that produce a single number per bucket:
Map<String, Long> headcount = people.stream()
.collect(Collectors.groupingBy(Person::role, Collectors.counting()));
Map<String, Integer> totalAgePerRole = people.stream()
.collect(Collectors.groupingBy(Person::role,
Collectors.summingInt(Person::age)));
Map<String, Double> avgAgePerRole = people.stream()
.collect(Collectors.groupingBy(Person::role,
Collectors.averagingDouble(Person::age)));
Map<String, Optional<Person>> oldestPerRole = people.stream()
.collect(Collectors.groupingBy(Person::role,
Collectors.maxBy(Comparator.comparingInt(Person::age))));counting()—Long, the bucket's size.summingInt/Long/Double(toX)— sum of the projected primitive.averagingInt/Long/Double(toX)—Doubleaverage.minBy(cmp)/maxBy(cmp)—Optional<T>extreme.summarizingInt/Long/Double(toX)—IntSummaryStatistics/ etc., the full count/sum/min/max/average bundle.
joining — concatenate strings
For streams of CharSequence:
String csv = words.stream().collect(Collectors.joining(","));
String pretty = words.stream().collect(Collectors.joining(", ", "[", "]"));Three overloads: no-arg (just concat), one-arg delimiter, three-arg delimiter + prefix + suffix. Faster than reduce("", String::concat) because it uses a StringBuilder under the hood and doesn't allocate quadratically. The right tool whenever the result of the pipeline is a single string.
mapping — transform then collect
Wraps another collector so the elements are transformed first. The most common use is inside groupingBy when you want to group by one thing and collect a projection of the elements rather than the elements themselves:
Map<String, List<String>> namesByRole = people.stream()
.collect(Collectors.groupingBy(
Person::role,
Collectors.mapping(Person::name, Collectors.toList())));Without mapping, the downstream toList() would collect whole Persons; with mapping(Person::name, ...), it collects only the names. Use it whenever you'd otherwise write groupingBy(...).entrySet().stream().map(...).collect(...) two passes back-to-back.
filtering (Java 9+) is the corresponding "drop some before collecting" wrapper:
Map<String, List<Person>> adultsByRole = people.stream()
.collect(Collectors.groupingBy(
Person::role,
Collectors.filtering(p -> p.age() >= 18, Collectors.toList())));The difference from stream.filter(...) before the collector: filtering keeps the key in the result map even when no elements pass — its bucket is just empty.
reducing — full general reduction as a collector
The collector form of reduce, used as a downstream when the standard ones don't fit:
Map<String, Optional<Person>> oldestPerRole = people.stream()
.collect(Collectors.groupingBy(
Person::role,
Collectors.reducing(BinaryOperator.maxBy(Comparator.comparingInt(Person::age)))));There are three overloads (one-arg, two-arg with identity, three-arg with identity + mapper + accumulator) matching the three reduce shapes from the previous chapter. The two-arg form is the most common as a downstream because it returns a plain T instead of an Optional<T>.
You rarely write reducing at the top of a pipeline — reduce is the terminal for that. You write it as a downstream of groupingBy/partitioningBy when you want per-bucket reduction.
collectingAndThen — post-process the result
Wraps a collector with a finishing function. The standard use is to make a collected List/Map unmodifiable, or to extract a final value from a summarizing* result:
List<String> immutableNames = people.stream()
.map(Person::name)
.collect(Collectors.collectingAndThen(
Collectors.toList(),
Collections::unmodifiableList));
Map<String, Long> immutableCounts = people.stream()
.collect(Collectors.collectingAndThen(
Collectors.groupingBy(Person::role, Collectors.counting()),
Collections::unmodifiableMap));It's also how you turn groupingBy(..., minBy(...)) into a plain value instead of Optional<T> — the finisher unwraps the Optional with a known default.
teeing — run two collectors in one pass
(Java 12+) Feed every element to two collectors at once and combine their results:
record Range(int min, int max) {}
Range range = nums.stream()
.collect(Collectors.teeing(
Collectors.minBy(Integer::compare),
Collectors.maxBy(Integer::compare),
(lo, hi) -> new Range(lo.orElseThrow(), hi.orElseThrow())));The two child collectors each see every element; the merger receives their two results. Useful when you'd otherwise stream twice — e.g. compute average and spot the outliers.
Picking the right collector
| You want the result to be | Use |
|---|---|
List<T> (immutable, common case) | stream.toList() (terminal) |
List<T> (mutable) | Collectors.toList() or toCollection(ArrayList::new) |
Set<T> | Collectors.toSet() or toCollection(LinkedHashSet::new) |
| Specific collection | Collectors.toCollection(supplier) |
Map<K, V> one-to-one | Collectors.toMap(k, v) (+ merger if needed) |
Map<K, List<T>> buckets | Collectors.groupingBy(k) |
Map<Boolean, List<T>> | Collectors.partitioningBy(pred) |
| Single string | Collectors.joining(delim, pre, suf) |
| Per-bucket count/sum/avg | groupingBy(k, counting() / summingInt(...) / ...) |
| Per-bucket projection | groupingBy(k, mapping(proj, toList())) |
| Per-bucket extreme | groupingBy(k, minBy(cmp) / maxBy(cmp)) |
| Per-bucket custom reduce | groupingBy(k, reducing(...)) |
| Two results in one pass | Collectors.teeing(c1, c2, merger) |
| Make result unmodifiable | wrap in collectingAndThen(c, Collections::unmodifiableList) |
A worked example: every collector on one dataset
The program below builds a list of Person records and runs each collector shape against it.
What to take from the run:
- The unguarded
toMap(Person::name, Person::age)at the end threwIllegalStateExceptionbecause twoPersons share the name "Alice". The standard fix is a third argument: aBinaryOperator<V>that says how to merge values when keys collide. Choose the merger to match your semantics (keep first, keep last, sum, concat) — that's what the earlierageByNamecall did with(a, b) -> a. groupingBy(Person::role)produced aMap<String, List<Person>>for free. Swapping the default downstreamtoList()forcounting(),summingInt(...),averagingDouble(...), ormaxBy(...)turned the per-bucket result from "a list" into a single number — same pipeline shape, different recipe in the downstream slot.mapping(Person::name, toList())is the answer to "I want to group by role, but my buckets should contain only names, not wholePersons." Pre-projecting downstream is almost always cleaner than collecting whole records and then mapping the values.partitioningBygave back bothtrueandfalsekeys even when one half could have been empty. That predictability is its raison d'être versusgroupingBy(predicate).teeingcollectedminandmaxin a single pass, then handed bothOptionals to a merger that built theRangerecord. Whenever you'd otherwise stream twice for two summaries, reach forteeing.collectingAndThen(toList(), Collections::unmodifiableList)is the classic finisher trick; the same shape unwraps agroupingBy(..., maxBy(...))fromMap<K, Optional<V>>toMap<K, V>when you've already proved every bucket is non-empty.
What's next
Every collector and intermediate in the part so far runs sequentially by default — one element at a time, in encounter order, on the calling thread. The next chapter, Java Parallel Streams, introduces the alternative scheduling — parallelStream() and stream().parallel() — what's safe to put inside a parallel pipeline, what isn't (mutating shared state, order-sensitive forEach, non-associative reduce), and how to tell whether parallel actually helps or makes the program slower.