Skip to content

How to Collect Stream Results in Java: Collectors Guide

Build a URL-shortener click report that uses groupingBy, partitioningBy, toMap, teeing and a custom Collector on Java 21, with 21 checks and the errors I hit.

how-to-tutorials java-streams java-collections java-tutorials
Gopi Gorantala
Reading Progress

On This Page

The report this code prints

This is what you'll have by the end. Twelve clicks from a URL shortener go in; a full analytics report comes out:

by country    : [BR, DE, IN, US]
per code      : {a1=3, b2=3, c3=3, d4=2, e5=1}
countries/code: {a1=[IN, US], b2=[DE, US], c3=[BR, IN], d4=[US], e5=[DE]}
humans/bots   : {false=9, true=3}
toMap totals  : {a1=3, b2=3, c3=3, d4=2, e5=1}
latency DESKTOP : n=4 min=38 avg=56.8 max=90
latency MOBILE  : n=6 min=36 avg=50.3 max=64
latency TABLET  : n=2 min=33 avg=51.5 max=70
human share   : 9 of 12 (75.0%)
busiest/ctry  : {BR=c3, DE=b2, IN=a1, US=d4}
codes         : a1|b2|c3|d4|e5

Every line comes from a different Collector, and each one is a single call to stream.collect(...). If you want to collect stream results in Java into something better than a plain list, this is the set of tools you need: groupingBy, partitioningBy, toMap, mapping, summarizingInt, teeing, collectingAndThen, and a collector you write yourself.

You should be comfortable with lambdas and method references already. If you haven't filtered a stream yet, read How to filter and transform a list with Java streams first, because this article starts where that one stops: at the terminal operation.

Here's the shape of the program:

List<ClickEvent> ──stream()──▶ collect(Collector) ──▶ Map / Set / String / record
                                   │
                    supplier → accumulator → combiner → finisher

I built this on my own machine's JDK 21 from a terminal. The one thing that cost me real time was step 9, nesting a collector inside another one and getting the tie-break right. I'll point at it when we get there.

What I had on the machine

Tool Version I used Check it with
JDK OpenJDK 21.0.12.1 java -version
javac 21.0.12.1 javac -version
IntelliJ IDEA 2026.2.x (menu paths below are from JetBrains docs, I did not open it) Help → About
Eclipse IDE 2026-06 (menu paths from Eclipse docs, not opened) Help → About Eclipse IDE

No Maven, no Gradle, no libraries. Everything here is the JDK. I tried to pull JUnit from Maven Central and the connection was refused in my build environment, so the tests in this article are plain main methods that print PASS or FAIL. They check the same things a JUnit test would. Moving them to JUnit 5 is a copy-and-paste job once you have it on your classpath; I cover that in the unit testing article (not published yet).

Check your JDK:

java -version

You need 21 for the records and the toList() calls. teeing and filtering need 12 and 9, so most of the article works on 17.

Lay out the click-analytics project

Make the folders. On macOS and Linux:

mkdir -p clicks/src/main/java/dev/ggorantala/clicks
mkdir -p clicks/src/test/java/dev/ggorantala/clicks
cd clicks

On Windows PowerShell, use New-Item -ItemType Directory -Force with the same paths.

The finished tree looks like this:

clicks/
├── src/
│   ├── main/java/dev/ggorantala/clicks/
│   │   ├── ClickEvent.java
│   │   ├── ClickReports.java
│   │   ├── Device.java
│   │   ├── Main.java
│   │   └── SampleData.java
│   └── test/java/dev/ggorantala/clicks/
│       └── Checks.java
└── out/

In IntelliJ IDEA

Use File → New → Project, pick Java, set the build system to IntelliJ, choose JDK 21, and name it clicks. Create the package dev.ggorantala.clicks under src. I wrote this from the JetBrains docs and haven't clicked through the wizard on this version, so if a label differs, trust your screen.

In Eclipse

File → New → Java Project, name it clicks, and under JRE choose an execution environment of JavaSE-21. Then File → New → Package. Same caveat: from the docs, not clicked.

Maven or Gradle

Nothing to add: there are no dependencies. A pom.xml with maven.compiler.release set to 21, or a Gradle build with java { toolchain { languageVersion = JavaLanguageVersion.of(21) } }, is all you'd need. I did not run either this time, so I'm not showing output for them.

How to collect stream results in Java: nine collectors, one report

Each step adds one method to ClickReports and one line to Main. Compile and run after every step if you like; the checkpoint at the end of the article shows the combined output.

Step 1: Define the data

You need an event type. A record is the shortest honest way to write one (more in How to use Java records).

src/main/java/dev/ggorantala/clicks/Device.java

package dev.ggorantala.clicks;

public enum Device { 
	DESKTOP, 
	MOBILE, 
	TABLET 
}

src/main/java/dev/ggorantala/clicks/ClickEvent.java

package dev.ggorantala.clicks;

/** One redirect served by the URL shortener. */
public record ClickEvent(String code, String country, Device device, int latencyMs, boolean bot) {
}

A record gives you equals, hashCode and accessors for free, so ClickEvent::country works as a method reference with no extra code. The reasons that matters are in How to implement equals() and hashCode().

Now some data. Twelve hand-written events are small enough to check by eye. A few thousand seeded random ones are what the tests use later.

src/main/java/dev/ggorantala/clicks/SampleData.java

package dev.ggorantala.clicks;

import java.util.ArrayList;
import java.util.List;
import java.util.Random;

public final class SampleData {

    private SampleData() { }

    /** Twelve hand-written events, small enough to check by eye. */
    public static List<ClickEvent> small() {
        return List.of(
            new ClickEvent("a1", "IN", Device.MOBILE,  42, false),
            new ClickEvent("a1", "IN", Device.DESKTOP, 55, false),
            new ClickEvent("a1", "US", Device.MOBILE,  61, false),
            new ClickEvent("b2", "US", Device.DESKTOP, 38, false),
            new ClickEvent("b2", "DE", Device.TABLET,  70, false),
            new ClickEvent("b2", "DE", Device.MOBILE,  64, true),
            new ClickEvent("c3", "IN", Device.MOBILE,  48, false),
            new ClickEvent("c3", "IN", Device.MOBILE,  51, false),
            new ClickEvent("c3", "BR", Device.DESKTOP, 90, false),
            new ClickEvent("d4", "US", Device.TABLET,  33, true),
            new ClickEvent("d4", "US", Device.MOBILE,  36, true),
            new ClickEvent("e5", "DE", Device.DESKTOP, 44, false));
    }

    /** Pseudo-random events with a fixed seed, for comparing collectors with plain loops. */
    public static List<ClickEvent> generated(int n, long seed) {
        String[] codes = {"a1", "b2", "c3", "d4", "e5", "f6", "g7"};
        String[] countries = {"IN", "US", "DE", "BR", "JP", "NG"};
        Device[] devices = Device.values();
        Random r = new Random(seed);
        List<ClickEvent> out = new ArrayList<>(n);
        for (int i = 0; i < n; i++) {
            out.add(new ClickEvent(
                codes[r.nextInt(codes.length)],
                countries[r.nextInt(countries.length)],
                devices[r.nextInt(devices.length)],
                20 + r.nextInt(180),
                r.nextInt(10) == 0));
        }
        return out;
    }
}

Checkpoint: nothing to run yet. Compile later in one go.

Step 2: Group clicks with groupingBy

Goal: split the list into one list per country.

src/main/java/dev/ggorantala/clicks/ClickReports.java (start of the file; we'll keep adding to it)

package dev.ggorantala.clicks;

import java.util.Comparator;
import java.util.IntSummaryStatistics;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import java.util.TreeSet;
import java.util.stream.Collector;
import java.util.stream.Collectors;

public final class ClickReports {

    private ClickReports() { }

    /** Step 2: group clicks by country. */
    public static Map<String, List<ClickEvent>> byCountry(List<ClickEvent> events) {
        return events.stream().collect(Collectors.groupingBy(ClickEvent::country));
    }
}

groupingBy takes a classifier function. Every element goes into the bucket named by the function's result. The one-argument form gives you a HashMap of ArrayLists. Look at the output line by country: [BR, DE, IN, US]. That order is HashMap's whim, and it happened to be alphabetical for four short strings. Don't rely on it. The next step shows the fix.

Step 3: Count per code, in sorted order

Goal: clicks per short code, keys sorted.

/** Step 3: count per short code, with the keys in sorted order. */
public static TreeMap<String, Long> countsByCode(List<ClickEvent> events) {
	return events.stream().collect(Collectors.groupingBy(
		ClickEvent::code, TreeMap::new, Collectors.counting()));
}

Three arguments now: the classifier, a map factory (TreeMap::new swaps out the default HashMap), and a downstream collector. The downstream collector decides what each bucket holds. counting() replaces the default list-of-elements with a Long. Notice it's a Long, not an Integer; the mistakes section below shows what happens when you assume otherwise.

Step 4: Transform values inside a group with mapping

Goal: for each code, the set of countries that clicked it, without duplicates.

/** Step 4: which countries hit which code, de-duplicated. */
public static Map<String, Set<String>> countriesPerCode(List<ClickEvent> events) {
	return events.stream().collect(Collectors.groupingBy(
		ClickEvent::code, TreeMap::new,
		Collectors.mapping(ClickEvent::country, Collectors.toCollection(TreeSet::new))));
}

mapping is map() for a downstream collector: it converts each element first, then hands the result to the next collector. toCollection(TreeSet::new) is how you ask for a specific set type, since toSet() promises nothing about which one you get.

Step 5: Split in two with partitioningBy

Goal: humans on one side, bots on the other.

/** Step 5: humans on one side, bots on the other. Both keys are always present. */
public static Map<Boolean, Long> humansAndBots(List<ClickEvent> events) {
	return events.stream().collect(Collectors.partitioningBy(
		ClickEvent::bot, Collectors.counting()));
}

partitioningBy takes a predicate and always returns both true and false keys, even if one side is empty. groupingBy on a boolean doesn't do that, and I prove the difference in the checks.

Step 6: Build a map with toMap

Goal: the same per-code totals as step 3, this time with toMap.

/** Step 6: code to total clicks, via toMap with a merge function. */
public static Map<String, Integer> totalsWithToMap(List<ClickEvent> events) {
	return events.stream().collect(Collectors.toMap(
		ClickEvent::code, e -> 1, Integer::sum, TreeMap::new));
}

toMap needs a key function and a value function. The third argument, the merge function, is what you pass when two elements produce the same key. Leave it out and the first duplicate throws. Four arguments adds the map factory again. For counting, groupingBy plus counting() is shorter and reads better. toMap earns its place when each key maps to one thing you computed from the element, like id → name.

Step 7: Summarise numbers in one pass

Goal: min, average and max latency per device.

/** Step 7: latency statistics per device in one pass. */
public static Map<Device, IntSummaryStatistics> latencyByDevice(List<ClickEvent> events) {
	return events.stream().collect(Collectors.groupingBy(
		ClickEvent::device,
		TreeMap::new,
		Collectors.summarizingInt(ClickEvent::latencyMs)));
}

summarizingInt returns an IntSummaryStatistics with count, sum, min, average and max from a single traversal. Three separate streams would walk the list three times. The TreeMap on an enum key sorts by declaration order, which is why the report prints DESKTOP, MOBILE, TABLET. (For enum keys on bigger data there's also EnumMap; see the EnumMap article, not yet published.)

Step 8: Two answers in one pass with teeing

Goal: the number of human clicks and the total, from one traversal, combined into a record.

/** Step 8: two answers from one pass with teeing. */
public record Share(long humans, long total) {
	public double humanPercent() {
		return total == 0 ? 0.0 : 100.0 * humans / total;
	}
}

public static Share humanShare(List<ClickEvent> events) {
	return events.stream().collect(Collectors.teeing(
		Collectors.filtering(e -> !e.bot(), Collectors.counting()),
		Collectors.counting(),
		Share::new));
}

teeing runs two collectors side by side on the same stream and merges their results with a function. It arrived in Java 12. filtering is a downstream filter, in Java 9. If you used .filter() before collect, the second collector would only see humans too, which is the bug filtering exists to avoid.

Step 9: Nest collectors with collectingAndThen

Goal: the busiest code in each country.

This is the step where my first attempt failed. I wrote it with maxBy, got an Optional<Map.Entry<...>> back, and couldn't see where to unwrap it. collectingAndThen is the answer: it takes a collector, runs it, and applies a finishing function to the result.

/** Step 9: top code per country, using collectingAndThen to unwrap the result. */
public static Map<String, String> busiestCodePerCountry(List<ClickEvent> events) {
	return events.stream().collect(Collectors.groupingBy(
		ClickEvent::country, TreeMap::new,
		Collectors.collectingAndThen(
			Collectors.groupingBy(ClickEvent::code, TreeMap::new, Collectors.counting()),
			counts -> counts.entrySet().stream()
				.max(Map.Entry.<String, Long>comparingByValue()
					.thenComparing(Map.Entry.comparingByKey(Comparator.reverseOrder())))
				.orElseThrow().getKey())));
}

The inner groupingBy counts clicks per code inside one country. The finishing lambda then finds the entry with the highest count. Ties matter: India has a1 twice and c3 twice. max with a plain value comparator returns whichever it meets last, which depends on iteration order. The thenComparing(... reverseOrder()) makes the smaller code win a tie, so the answer is a1 every time. If you haven't worked with comparator chains, How to implement Comparable and Comparator covers thenComparing.

orElseThrow() is safe here because a group only exists if it has at least one element, so the inner map is never empty.

Step 10: Write your own Collector

Goal: a collector that gives a sorted, pipe-separated list of distinct codes.

/** Step 10: your own Collector, for when the built-ins do not cover it. */
public static Collector<ClickEvent, ?, String> distinctCodes() {
	return Collector.of(
		TreeSet<String>::new,
		(set, e) -> set.add(e.code()),
		(left, right) -> { left.addAll(right); return left; },
		set -> String.join("|", set));
}

Collector.of takes four things: a supplier that creates an empty container, an accumulator that folds one element in, a combiner that merges two containers (used by parallel streams), and a finisher that turns the container into the final result. You rarely need this. When you do, the combiner is the part to get right, and the checks below run it on a parallel stream to prove it.

Step 11: Print everything

src/main/java/dev/ggorantala/clicks/Main.java

package dev.ggorantala.clicks;

import java.util.List;

public class Main {
    public static void main(String[] args) {
        List<ClickEvent> events = SampleData.small();

        System.out.println("by country    : " + ClickReports.byCountry(events).keySet());
        System.out.println("per code      : " + ClickReports.countsByCode(events));
        System.out.println("countries/code: " + ClickReports.countriesPerCode(events));
        System.out.println("humans/bots   : " + ClickReports.humansAndBots(events));
        System.out.println("toMap totals  : " + ClickReports.totalsWithToMap(events));
        ClickReports.latencyByDevice(events).forEach((d, s) ->
            System.out.printf("latency %-8s: n=%d min=%d avg=%.1f max=%d%n",
                d, s.getCount(), s.getMin(), s.getAverage(), s.getMax()));
        var share = ClickReports.humanShare(events);
        System.out.printf("human share   : %d of %d (%.1f%%)%n", share.humans(), share.total(), share.humanPercent());
        System.out.println("busiest/ctry  : " + ClickReports.busiestCodePerCountry(events));
        System.out.println("codes         : " + events.stream().collect(ClickReports.distinctCodes()));
    }
}

Checkpoint: this is the file that ties it together. Next, run it.

Compile it and read the output

From the clicks folder:

mkdir -p out
javac --release 21 -d out $(find src -name '*.java')
java -cp out dev.ggorantala.clicks.Main

On Windows PowerShell, list the files with Get-ChildItem -Recurse -Filter *.java src | ForEach-Object FullName and pass them to javac. In IntelliJ, right-click Main and choose Run; in Eclipse, Run As → Java Application. Same program either way.

You should see exactly the report from the top of the article. Walk through two lines to make sure you believe them:

  • humans/bots: {false=9, true=3}. Events 6, 10 and 11 have bot = true. That's three.
  • latency TABLET: n=2 min=33 avg=51.5 max=70. The two tablet clicks are 70 and 33; (70 + 33) / 2 = 51.5.

If your output matches, the hard part's done.

Twenty-one checks, and why they compare against loops

A collector call is dense. The way I trust one is to write the dumb loop version for the same data and compare the answers. The checks use 5,000 seeded random events for that.

src/test/java/dev/ggorantala/clicks/Checks.java

package dev.ggorantala.clicks;

import java.util.*;
import java.util.stream.*;

public class Checks {
    static int passed = 0, failed = 0;

    static void check(String name, boolean ok) {
        if (ok) { passed++; System.out.println("PASS  " + name); }
        else    { failed++; System.out.println("FAIL  " + name); }
    }

    public static void main(String[] args) {
        List<ClickEvent> small = SampleData.small();
        List<ClickEvent> big = SampleData.generated(5000, 42L);

        // loop versions to compare against
        Map<String, Long> loopCounts = new TreeMap<>();
        for (ClickEvent e : big) loopCounts.merge(e.code(), 1L, Long::sum);
        check("countsByCode matches loop on 5000 events", ClickReports.countsByCode(big).equals(loopCounts));

        long bots = 0;
        for (ClickEvent e : big) if (e.bot()) bots++;
        Map<Boolean, Long> hb = ClickReports.humansAndBots(big);
        check("partitioningBy bot count matches loop", hb.get(true) == bots);
        check("partitioningBy humans + bots = total", hb.get(true) + hb.get(false) == big.size());

        Map<String, Integer> toMapTotals = ClickReports.totalsWithToMap(big);
        check("toMap totals equal counting totals",
            toMapTotals.entrySet().stream().allMatch(en -> en.getValue().longValue() == loopCounts.get(en.getKey())));

        check("byCountry keeps every event", ClickReports.byCountry(big).values().stream().mapToInt(List::size).sum() == big.size());
        check("byCountry keeps encounter order inside a group",
            ClickReports.byCountry(small).get("IN").equals(
                small.stream().filter(e -> e.country().equals("IN")).toList()));

        check("small: a1 has 3 clicks", ClickReports.countsByCode(small).get("a1") == 3);
        check("small: countries per code d4 = [US]", ClickReports.countriesPerCode(small).get("d4").equals(Set.of("US")));
        check("small: countriesPerCode is sorted", ClickReports.countriesPerCode(small).get("a1").toString().equals("[IN, US]"));

        // partitioningBy always has both keys, groupingBy does not
        List<ClickEvent> noBots = small.stream().filter(e -> !e.bot()).toList();
        Map<Boolean, Long> p = ClickReports.humansAndBots(noBots);
        check("partitioningBy keeps the true key with 0", p.containsKey(true) && p.get(true) == 0L);
        Map<Boolean, Long> g = noBots.stream().collect(Collectors.groupingBy(ClickEvent::bot, Collectors.counting()));
        check("groupingBy drops the empty key", !g.containsKey(true));

        // summarizingInt vs manual
        IntSummaryStatistics mob = ClickReports.latencyByDevice(big).get(Device.MOBILE);
        int[] ls = big.stream().filter(e -> e.device() == Device.MOBILE).mapToInt(ClickEvent::latencyMs).toArray();
        check("summarizingInt count", mob.getCount() == ls.length);
        check("summarizingInt min/max", mob.getMin() == Arrays.stream(ls).min().getAsInt() && mob.getMax() == Arrays.stream(ls).max().getAsInt());
        check("summarizingInt sum", mob.getSum() == Arrays.stream(ls).asLongStream().sum());

        // teeing
        var sh = ClickReports.humanShare(big);
        check("teeing total", sh.total() == big.size());
        check("teeing humans = total - bots", sh.humans() == big.size() - bots);
        check("humanPercent of empty list is 0", ClickReports.humanShare(List.of()).humanPercent() == 0.0);

        // busiest per country: tie goes to the smaller code
        check("busiest in IN breaks tie to a1", ClickReports.busiestCodePerCountry(small).get("IN").equals("a1"));

        // custom collector, sequential and parallel
        String seq = big.stream().collect(ClickReports.distinctCodes());
        String par = big.parallelStream().collect(ClickReports.distinctCodes());
        check("custom collector sequential", seq.equals("a1|b2|c3|d4|e5|f6|g7"));
        check("custom collector parallel gives same answer", par.equals(seq));

        // results of Collectors.toList / groupingBy are not promised mutable or immutable
        List<ClickEvent> tl = small.stream().toList();
        boolean threw = false;
        try { tl.add(small.get(0)); } catch (UnsupportedOperationException ex) { threw = true; }
        check("Stream.toList() is unmodifiable", threw);

        System.out.println();
        System.out.println(passed + " passed, " + failed + " failed");
        if (failed > 0) System.exit(1);
    }
}

Run it:

javac --release 21 -d out -cp out src/test/java/dev/ggorantala/clicks/Checks.java
java -cp out dev.ggorantala.clicks.Checks
PASS  countsByCode matches loop on 5000 events
PASS  partitioningBy bot count matches loop
PASS  partitioningBy humans + bots = total
PASS  toMap totals equal counting totals
PASS  byCountry keeps every event
PASS  byCountry keeps encounter order inside a group
PASS  small: a1 has 3 clicks
PASS  small: countries per code d4 = [US]
PASS  small: countriesPerCode is sorted
PASS  partitioningBy keeps the true key with 0
PASS  groupingBy drops the empty key
PASS  summarizingInt count
PASS  summarizingInt min/max
PASS  summarizingInt sum
PASS  teeing total
PASS  teeing humans = total - bots
PASS  humanPercent of empty list is 0
PASS  busiest in IN breaks tie to a1
PASS  custom collector sequential
PASS  custom collector parallel gives same answer
PASS  Stream.toList() is unmodifiable

21 passed, 0 failed

Two of these deserve a second look. "partitioningBy keeps the true key with 0" and "groupingBy drops the empty key" are the same data run through two collectors that look interchangeable, and they aren't. If you later switch one for the other, a report column that used to say 0 will quietly vanish.

What collect() does with a Collector

stream.collect(collector) is a mutable reduction. Instead of combining elements into new values the way reduce does, it fills a container. The four functions in a Collector are the whole story:

  1. The supplier makes an empty container: an ArrayList, a TreeMap, a counter.
  2. The accumulator takes the container and one element and folds the element in.
  3. The combiner merges two containers. A sequential stream never calls it. A parallel stream splits the work, fills several containers, and merges them at the end.
  4. The finisher converts the container into the result you wanted. counting() accumulates into a long counter and finishes by boxing it to Long.

Every built-in in Collectors is a prebuilt version of these four. groupingBy is a collector whose container is a map and whose accumulator hands each element to a downstream collector for that key. That's why they nest so naturally: a collector inside a collector is a container inside a container.

The map and list types you get back from the short forms aren't specified. In my run, groupingBy returned a java.util.HashMap holding ArrayLists, and partitioningBy returned a java.util.stream.Collectors$Partition, a private class. Treat them as Map and List, nothing more. If you need a particular type, ask for it with a factory argument like TreeMap::new.

For the deeper story on how streams pull elements through a pipeline, see How does the Streams API work.

Collector mistakes I made on purpose

I forced each of these so you can recognise them. All outputs are from my run on JDK 21.

Duplicate key in toMap. Collectors.toMap(ClickEvent::code, ClickEvent::latencyMs) with no merge function:

java.lang.IllegalStateException: Duplicate key a1 (attempted merging values 42 and 55)

Cause: two events share a code and toMap doesn't guess which wins. Fix: pass a merge function (Integer::sum, (a, b) -> a, or Math::max), or switch to groupingBy if you wanted all the values. There's a whole page on this one: IllegalStateException: Duplicate key in Collectors.toMap.

Null value in toMap. A value function that returns null:

java.lang.NullPointerException: null

toMap uses HashMap.merge internally, which rejects null values, and the message is empty, which makes it annoying to trace. Replace nulls before collecting, or use a loop.

Null key in groupingBy. An event whose code is null:

java.lang.NullPointerException: element cannot be mapped to a null key

That's at least a readable message. Map the null to a placeholder like "unknown" in the classifier.

Unwrapping an empty maxBy. collectingAndThen(maxBy(...), Optional::get) on an empty stream:

java.util.NoSuchElementException: No value present

Use orElse or return the Optional itself when emptiness is a real possibility. For more on that, see How to use Optional in Java.

Adding to a collected list. Stream.toList() and Collectors.toUnmodifiableList() both gave me:

java.lang.UnsupportedOperationException: null

While Collectors.toList() happily let me add, and handed back a java.util.ArrayList. That doesn't mean you should rely on it: the Javadoc makes no promise about mutability for toList(). If you need a list you can change, write Collectors.toCollection(ArrayList::new).

Wrong value type. Map<String, Integer> m = ...collect(groupingBy(ClickEvent::code, counting())):

Bad1.java:7: error: incompatible types: inference variable D has incompatible equality constraints Integer,Long

counting() produces Long. Declare Map<String, Long>, or use summingInt(e -> 1) if you really need Integer.

Closing the paren too early. Writing .collect(groupingBy(ClickEvent::code), counting()):

Bad2.java:7: error: no suitable method found for collect(Collector<ClickEvent,CAP#1,Map<String,List<ClickEvent>>>,Collector<Object,CAP#2,Long>)
    method Stream.<R#1>collect(Supplier<R#1>,BiConsumer<R#1,? super ClickEvent>,BiConsumer<R#1,R#1>) is not applicable

The error mentions a three-argument collect that you've never heard of. The real problem is a misplaced ): counting() belongs inside groupingBy. I've done this in a real project and stared at it for far too long.

Reusing a stream. Calling two terminal operations on one stream variable:

java.lang.IllegalStateException: stream has already been operated upon or closed

Call list.stream() again. A stream is a one-shot pipeline.

Old language level. javac --release 8 on the record:

src/main/java/dev/ggorantala/clicks/ClickEvent.java:4: error: records are not supported in -source 8

And Collectors.teeing under --release 11 fails with cannot find symbol, because it didn't exist until Java 12. If you must target 11, replace teeing with two passes.

The ordering trap. The first output line, by country : [BR, DE, IN, US], looks sorted, and it is the HashMap that put it there. Change the country codes and it may not be. I only know it's luck because I checked groupingBy's map class in the forced run: java.util.HashMap. If your report must be stable, pass TreeMap::new or LinkedHashMap::new.

Choosing a collector in real code

A short opinion list, from reading a lot of reporting code:

  • Counting or summing per key: use groupingBy with a downstream collector. It's shorter than toMap with a merge function, and the downstream says what the bucket holds.
  • Yes/no split: partitioningBy. You get both keys, always. That alone is worth the name.
  • One key, one value, keys unique by construction: toMap. Like looking up users by id. If duplicates are even possible, give it the merge function on day one.
  • Always say the map type when the output order matters. The default HashMap is wrong for anything a person reads.
  • Don't nest more than two levels in one expression. Step 9 is already at my limit. Pull the inner part into a named method.
  • Parallel streams: the combiner of a custom collector must be correct and the accumulator must not touch shared state. My parallel check passed, but I'd benchmark before using parallelStream() on anything small. Overhead usually wins.

Next reads, once they're published: flatMap for nested collections and chaining comparators. Both are forward links.

Version notes: Collectors.toList()'s behaviour hasn't changed, but Stream.toList() (Java 16) is the shorter choice when you want an unmodifiable list. teeing is Java 12, filtering and flatMapping are Java 9. Nothing in this article needs a preview flag.

What to remember

  • collect fills a container using supplier, accumulator, combiner and finisher; the built-in collectors are prebuilt combinations.
  • Use groupingBy(classifier, mapFactory, downstream) when output order matters and when you want something other than a list in each bucket.
  • partitioningBy always returns both keys; groupingBy on a boolean may not.
  • toMap needs a merge function whenever duplicate keys are possible, and it rejects null values.
  • Don't depend on the mutability or the concrete class of what toList(), groupingBy or partitioningBy return.
  • Check a collector against the loop it replaces, on more than a dozen elements.

Collectors questions people ask

How do I group a list by a field and count each group in Java?
Use Collectors.groupingBy(Item::field, Collectors.counting()). The result is a Map<FieldType, Long>. Add TreeMap::new as the middle argument if you want sorted keys.

What's the difference between groupingBy and partitioningBy?
groupingBy makes any number of buckets, chosen by a function. partitioningBy makes exactly two, true and false, chosen by a predicate, and both keys are always in the result even when one is empty.

How do I stop toMap throwing "Duplicate key"?
Pass a third argument, the merge function, such as (first, second) -> first or Integer::sum. If you want to keep all the values, use groupingBy instead.

Can I get a mutable list out of a stream in Java 21?
Yes: collect(Collectors.toCollection(ArrayList::new)). Stream.toList() is unmodifiable, and Collectors.toList() doesn't promise either way.

When would I write my own Collector?
When the built-ins don't fit and you'll reuse the logic: a sorted, de-duplicated joined string, a histogram with fixed buckets, a top-N. For a one-off, a loop or reduce is usually clearer.

how-to-tutorialsjava-streamsjava-collectionsjava-tutorials

Staff engineer architecting Kafka and Flink platforms for banking, where a dropped event is a reconciliation break, not a retry. Through javahandbook.com, I share production-tested guidance on building reliable event-driven systems.

Comments