> ## Content Index
> Fetch the complete content index at: https://www.ggorantala.dev/llms.txt
> Use this file to discover other available public pages before exploring further.

# How to Collect Stream Results in Java: Collectors Guide
- URL: https://www.ggorantala.dev/how-to-collect-stream-results-java/
- Published: 2026-10-01T02:17:00.000Z
- Updated: 2026-10-31T03:48:41.000Z
- Description: 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.
- Author: Gopi Gorantala
- Tags: how-to-tutorials, java-streams, java-collections, java-tutorials

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

```log
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](https://ggorantala.dev/how-to-filter-transform-list-java-streams/?ref=ggorantala.dev) first, because this article starts where that one stops: at the terminal operation.

Here's the shape of the program:

```log
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](https://ggorantala.dev/how-to-write-unit-tests-junit-5-java/?ref=ggorantala.dev) (not published yet).

Check your JDK:

```bash
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:

```bash
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:

```log
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](https://ggorantala.dev/how-to-use-java-records/?ref=ggorantala.dev)).

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

```java
package dev.ggorantala.clicks;

public enum Device { 
	DESKTOP, 
	MOBILE, 
	TABLET 
}

```

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

```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()](https://ggorantala.dev/how-to-implement-equals-hashcode-java/?ref=ggorantala.dev).

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`

```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)

```java
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 `ArrayList`s. 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.

```java
/** 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.

```java
/** 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.

```java
/** 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`.

```java
/** 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.

```java
/** 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](https://ggorantala.dev/how-to-use-enummap-enumset-java/?ref=ggorantala.dev), 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.

```java
/** 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.

```java
/** 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](https://ggorantala.dev/how-to-implement-comparable-comparator-java/?ref=ggorantala.dev) 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.

```java
/** 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`

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

```bash
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`

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

```bash
javac --release 21 -d out -cp out src/test/java/dev/ggorantala/clicks/Checks.java
java -cp out dev.ggorantala.clicks.Checks

```

```log
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 `ArrayList`s, 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](https://ggorantala.dev/how-does-streams-api-work/?ref=ggorantala.dev).

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

```log
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](https://ggorantala.dev/java-illegalstateexception-duplicate-key-collectors-tomap/?ref=ggorantala.dev).

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

```log
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`:

```log
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:

```log
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](https://ggorantala.dev/how-to-use-optional-java/?ref=ggorantala.dev).

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

```log
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()))`:

```log
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())`:

```log
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:

```log
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:

```log
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](https://ggorantala.dev/how-to-use-flatmap-java/?ref=ggorantala.dev) and [chaining comparators](https://ggorantala.dev/how-to-chain-comparators-java/?ref=ggorantala.dev). 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.