W3docs

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 some List — usually mutable, but not guaranteed. For the unmodifiable form you want most of the time, use stream.toList() (the terminal, not the collector).
  • Collectors.toSet() is unordered — typically HashSet. If you need stable iteration order, ask for it explicitly with toCollection(LinkedHashSet::new).
  • Collectors.toUnmodifiableList() and toUnmodifiableSet() (Java 10+) return immutable results — these are the collector-form equivalents of stream.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 + newAge

For 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:

ProducesOn duplicate key
toMapMap<K, V> (one V per K)Throws unless you give a merger
groupingByMap<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)Double average.
  • 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 beUse
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 collectionCollectors.toCollection(supplier)
Map<K, V> one-to-oneCollectors.toMap(k, v) (+ merger if needed)
Map<K, List<T>> bucketsCollectors.groupingBy(k)
Map<Boolean, List<T>>Collectors.partitioningBy(pred)
Single stringCollectors.joining(delim, pre, suf)
Per-bucket count/sum/avggroupingBy(k, counting() / summingInt(...) / ...)
Per-bucket projectiongroupingBy(k, mapping(proj, toList()))
Per-bucket extremegroupingBy(k, minBy(cmp) / maxBy(cmp))
Per-bucket custom reducegroupingBy(k, reducing(...))
Two results in one passCollectors.teeing(c1, c2, merger)
Make result unmodifiablewrap 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.

java— editable, runs on the server

What to take from the run:

  • The unguarded toMap(Person::name, Person::age) at the end threw IllegalStateException because two Persons share the name "Alice". The standard fix is a third argument: a BinaryOperator<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 earlier ageByName call did with (a, b) -> a.
  • groupingBy(Person::role) produced a Map<String, List<Person>> for free. Swapping the default downstream toList() for counting(), summingInt(...), averagingDouble(...), or maxBy(...) 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 whole Persons." Pre-projecting downstream is almost always cleaner than collecting whole records and then mapping the values.
  • partitioningBy gave back both true and false keys even when one half could have been empty. That predictability is its raison d'être versus groupingBy(predicate).
  • teeing collected min and max in a single pass, then handed both Optionals to a merger that built the Range record. Whenever you'd otherwise stream twice for two summaries, reach for teeing.
  • collectingAndThen(toList(), Collections::unmodifiableList) is the classic finisher trick; the same shape unwraps a groupingBy(..., maxBy(...)) from Map<K, Optional<V>> to Map<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.

Practice

Practice
`people.stream().collect(Collectors.toMap(Person::name, Person::age))` throws `IllegalStateException` when two `Person`s share a name. Which fix matches the intent of 'sum their ages when names collide'?
`people.stream().collect(Collectors.toMap(Person::name, Person::age))` throws `IllegalStateException` when two `Person`s share a name. Which fix matches the intent of 'sum their ages when names collide'?
Was this page helpful?