How do Collectors.groupingBy and toMap differ in failure modes?
They build almost the same thing and fail completely differently. toMap throws IllegalStateException on a duplicate key and NullPointerException on a null value; groupingBy does neither, because a group is a list and a list can hold anything. Both defaults are choices, and toMap's are the ones that reach production.
The Answer
Say this in the room. 45 seconds.
- Both turn a stream into a
Map. The difference is entirely in what they do when the data is not what you assumed. toMapthrowsIllegalStateExceptionon a duplicate key. The message names the key and both values, which is the one good thing about the failure.toMapthrowsNullPointerExceptionon a null value — even thoughHashMapaccepts nulls happily. It merges internally, and merge rejects null.groupingBydoes neither. Its value is a collection, so duplicates append and there is nothing to be null.- Fix
toMapwith the three-argument form: a merge function that says what a collision means. - And
groupingBy's real power is the downstream collector — counting, summing, mapping, or anothergroupingBy. Most people only ever use the one-argument form.
Understand It
The duplicate key
toMap is the obvious choice for "index this list by a field", and it is correct exactly as long as the field is unique. The moment it is not:
try {
Map<String, String> byDept = STAFF.stream()
.collect(Collectors.toMap(Employee::dept, Employee::name));
System.out.println(" " + byDept);
} catch (IllegalStateException e) {
System.out.println(" toMap : " + e.getClass().getSimpleName() + " — " + e.getMessage());
}
Map<String, List<String>> grouped = STAFF.stream()
.collect(Collectors.groupingBy(Employee::dept,
Collectors.mapping(Employee::name, Collectors.toList())));
System.out.println(" groupingBy : " + sorted(grouped));
Map<String, String> merged = STAFF.stream()
.collect(Collectors.toMap(Employee::dept, Employee::name, (a, b) -> a + "+" + b));
System.out.println(" toMap+merge: " + sorted(merged)); toMap : IllegalStateException — Duplicate key eng (attempted merging values Ana and Bo)
groupingBy : {eng=[Ana, Bo], ops=[Ed], sales=[Cy, Di]}
toMap+merge: {eng=Ana+Bo, ops=Ed, sales=Cy+Di}Three things worth noticing.
The exception is good, and it is still an outage. It names the key and both values, which is more than most JDK exceptions give you. But it throws at runtime on data, so it ships fine and fails on the first customer with two of something.
groupingBy cannot have this problem. Its value is a collection, so a second entry for a key is an append rather than a conflict. The failure mode was designed out, not handled.
The three-argument toMap makes the decision explicit. (a, b) -> a keeps the first, (b, a) -> b keeps the last, and anything else combines them. The two-argument form is not "the simple version" — it is the version that asserts uniqueness, and you should only write it when you can prove it.
The null that a HashMap would have accepted
This one surprises people who know HashMap allows null values:
List<Employee> one = List.of(new Employee("Ana", "eng", 120));
Map<String, Integer> bonuses = new HashMap<>();
bonuses.put("Ana", null);
try {
Map<String, Integer> m = one.stream()
.collect(Collectors.toMap(Employee::name, e -> bonuses.get(e.name())));
System.out.println(" toMap : " + m);
} catch (NullPointerException e) {
System.out.println(" toMap : NullPointerException");
}
Map<String, Integer> plain = new HashMap<>();
one.forEach(e -> plain.put(e.name(), bonuses.get(e.name())));
System.out.println(" plain put : " + plain); toMap : NullPointerException
plain put : {Ana=null}The same key and the same null value: put accepts it, toMap throws. toMap is implemented with Map.merge, and merge rejects a null value by contract — so the collector inherits a restriction the map itself does not have.
It is worth knowing because the null usually comes from a lookup that missed, and the stack trace points at the collector rather than at the missing data. If nulls are possible, filter them out first and decide deliberately what an absent value means.
groupingBy is a two-argument function people use with one
The single-argument form gives you Map<K, List<T>>. That is the least interesting thing it can do. The second argument is a downstream collector that decides what each group becomes:
System.out.println(" counting : " + sorted(STAFF.stream()
.collect(Collectors.groupingBy(Employee::dept, Collectors.counting()))));
System.out.println(" summing : " + sorted(STAFF.stream()
.collect(Collectors.groupingBy(Employee::dept, Collectors.summingInt(Employee::salary)))));
System.out.println(" averaging : " + sorted(STAFF.stream()
.collect(Collectors.groupingBy(Employee::dept, Collectors.averagingInt(Employee::salary)))));
System.out.println(" partitioning : " + STAFF.stream()
.collect(Collectors.partitioningBy(e -> e.salary() >= 85, Collectors.counting())));
System.out.println(" top per dept : " + sorted(STAFF.stream()
.collect(Collectors.groupingBy(Employee::dept,
Collectors.collectingAndThen(
Collectors.maxBy(Comparator.comparingInt(Employee::salary)),
best -> best.map(Employee::name).orElse("none")))))); counting : {eng=2, ops=1, sales=2}
summing : {eng=215, ops=60, sales=155}
averaging : {eng=107.5, ops=60.0, sales=77.5}
partitioning : {false=2, true=3}
top per dept : {eng=Ana, ops=Ed, sales=Di}The last one is the pattern worth memorising. maxBy returns an Optional, which is almost never what you want nested inside a map, and collectingAndThen unwraps it as the group is finished.
Two more worth knowing by name:
partitioningByisgroupingByrestricted to a boolean, and it always returns both keys —trueandfalse— even when one side is empty.groupingBywith a boolean function does not; an empty side is simply absent, which is a different bug.groupingBynests. The downstream collector can be anothergroupingBy, which gives youMap<String, Map<String, List<Employee>>>from one pass.
The map you get is not the map you may have assumed
Map<String, Long> byDept = STAFF.stream()
.collect(Collectors.groupingBy(Employee::dept, Collectors.counting()));
Map<String, Long> ordered = STAFF.stream()
.collect(Collectors.groupingBy(Employee::dept, TreeMap::new, Collectors.counting()));
System.out.println(" default type : " + byDept.getClass().getSimpleName());
System.out.println(" chosen type : " + ordered.getClass().getSimpleName());
System.out.println(" sorted keys : " + ordered.keySet()); default type : HashMap
chosen type : TreeMap
sorted keys : [eng, ops, sales]groupingBy returns a HashMap, so iteration order is unspecified. Code that prints a grouped map and looks correct is relying on an implementation detail — and a page like this one that pastes such output is asserting something the API never promised, which is why every map above went through a TreeMap before printing.
The three-argument form takes a map factory. Use TreeMap::new when you need order, or LinkedHashMap::new to keep encounter order. toMap has the same overload, as its fourth argument.
Neither collector promises a mutable or an immutable result either. Since Java 10 say what you mean with toUnmodifiableMap, or collectingAndThen with Map::copyOf.
Reference
Which collector for which shape, and the defaults worth knowing. Copy from here.
Choosing one
| You want | Use |
|---|---|
| Group into lists | groupingBy(Entity::key) |
| Group and count | groupingBy(key, counting()) |
| Group and sum | groupingBy(key, summingInt(Entity::amount)) |
| Group and extract a field | groupingBy(key, mapping(Entity::name, toList())) |
| Group and take the best | groupingBy(key, collectingAndThen(maxBy(cmp), unwrap)) |
| Two-level index | groupingBy(outer, groupingBy(inner)) |
| Split on a boolean | partitioningBy(pred) — always both keys |
| Index by a unique key | toMap(key, value) — throws on a duplicate |
| Index with a collision rule | toMap(key, value, (a, b) -> a) |
| A specific map type | toMap(k, v, merge, TreeMap::new) or groupingBy(k, TreeMap::new, downstream) |
| Count, sum, min, max, average in one pass | summarizingInt(Entity::amount) |
| Two different collectors in one pass | teeing(a, b, merger) — since 12 |
| An immutable result | toUnmodifiableList/Set/Map — since 10 |
| Join strings | joining(", ", "[", "]") |
The idioms worth memorising
// Top item per group, with no Optional left in the map type.
Map<String, Employee> best = staff.stream().collect(
groupingBy(Employee::dept,
collectingAndThen(maxBy(comparingInt(Employee::salary)), Optional::orElseThrow)));
// Sorted output — groupingBy returns a HashMap, whose order is unspecified.
Map<String, Long> byDept = staff.stream()
.collect(groupingBy(Employee::dept, TreeMap::new, counting()));
// Safe indexing: state what a duplicate means.
Map<String, Employee> byEmail = staff.stream()
.collect(toMap(Employee::email, identity(), (a, b) -> a));
// Min and max in ONE pass.
var range = staff.stream().collect(teeing(
minBy(comparingInt(Employee::salary)),
maxBy(comparingInt(Employee::salary)),
(lo, hi) -> lo.orElseThrow().name() + ".." + hi.orElseThrow().name()));
// Everything numeric about a field, one pass.
IntSummaryStatistics stats = staff.stream().collect(summarizingInt(Employee::salary));
stats.getAverage(); stats.getMax(); stats.getCount();
Defaults that surprise people
| Collector | Default |
|---|---|
toMap on a duplicate key | throws IllegalStateException |
toMap with a null value | throws NullPointerException — it uses Map.merge |
groupingBy map type | HashMap — iteration order unspecified |
groupingBy list type | unspecified; happens to be ArrayList, not promised |
groupingBy with a boolean | only the keys it saw — an empty side is absent |
partitioningBy | always both true and false |
Collectors.toList() | mutability never promised |
Stream.toList() (16+) | unmodifiable, and allows nulls |
Scenarios
Real situations, with the decision and the argument.
1. A report crashes with IllegalStateException: Duplicate key on the first day in production.
toMap with a key that is unique in the fixture and not in reality — a customer with two orders, a name that repeats, an email shared by two accounts.
The fix is not simply adding (a, b) -> a, because that silently discards data and someone will ask later which one was kept. Decide what a collision means: if the key genuinely should be unique, the right change may be upstream validation and letting this throw. If it should not, the value type is wrong and it wants groupingBy returning a list.
2. A dashboard shows the wrong department at the top and only in production.
Almost always iteration order. groupingBy returns a HashMap, so order is unspecified — and it is stable for a given key set and JDK, which is worse than random, because it looks deterministic in testing and changes when the data or the JDK does.
Pass a map factory — TreeMap::new for sorted keys, LinkedHashMap::new for encounter order. Anywhere a grouped map is rendered, printed or compared in a test, the factory is not optional.
3. groupingBy on a boolean, and get(true) throws a NullPointerException on a quiet day.
groupingBy only creates keys it actually encountered, so a day with no matching records leaves get(true) returning null. It works every day until the one where nothing qualified.
partitioningBy always returns both keys and is what the boolean case is for. getOrDefault(true, List.of()) is the smaller change if the grouping is elsewhere in the code — but if the classifier is a predicate, the wrong collector is being used.
4. A memory spike when grouping a large result set.
groupingBy materialises every element into lists. Grouping ten million rows to count them holds all ten million in memory to produce a handful of numbers.
The downstream collector is the fix — counting(), summingInt, summarizingInt — because it accumulates instead of collecting. This is the most common reason the single-argument groupingBy is the wrong call: it is not just less expressive, it is quadratically less efficient in memory for aggregate queries. For genuinely large data, the aggregation belongs in the database.
5. Someone mutates the list inside a grouped map and it works, so it ships.
groupingBy promises neither the map type, nor the list type, nor that either is mutable. It happens to give you HashMap and ArrayList today.
That code breaks the day someone adds toUnmodifiableList() as the downstream collector, or a JDK changes the default — and it breaks at runtime with UnsupportedOperationException, far from the change. Say what you want explicitly: toList() for mutable, toUnmodifiableList() for not. Relying on an unspecified default is the bug, whether or not it is currently working.
Interviewer's Next Move
The question after the question. This is what the round is actually testing.
1. "How do groupingBy and toMap differ?"
They build a similar map and fail completely differently. toMap throws IllegalStateException on a duplicate key and NullPointerException on a null value; groupingBy can do neither, because its value is a collection.
2. "Your toMap just threw in production. What happened, and what is the fix?" Two rows shared a key. The three-argument form takes a merge function that states what a collision means — keep the first, keep the last, or combine. The two-argument form is an assertion that the key is unique.
3. "Why does toMap reject a null value when HashMap accepts one?"
It is implemented with Map.merge, and merge rejects null by contract. The collector inherits a restriction the map does not have, which is why the failure feels inconsistent.
4. "What is a downstream collector?"
The second argument to groupingBy, deciding what each group becomes — counting, summingInt, mapping, collectingAndThen, or another groupingBy. Most people only ever use the one-argument form, which is the least useful one.
5. "groupingBy with a boolean, or partitioningBy?"
partitioningBy always returns both true and false keys even when one side is empty. groupingBy omits the empty side, so downstream code that assumes both keys exist gets a null.
6. "What map implementation does groupingBy return, and does the order matter?"
A HashMap, with unspecified iteration order. If order matters, pass a map factory — TreeMap::new or LinkedHashMap::new. Relying on the default order is depending on an implementation detail.
7. "How would you get the highest-paid person in each department?"
groupingBy with collectingAndThen(maxBy(comparing(...)), Optional::get) — or orElse rather than get, since an empty group cannot occur here but the type says it can.
8. "How do you compute a min and a max in one pass?"
Collectors.teeing, since Java 12, which runs two collectors over the same stream and merges the results. Before that it was two passes or a hand-written collector.
Code traps
Trap A — predict before you run:
Map<String, Integer> byName = employees.stream()
.collect(Collectors.toMap(Employee::name, Employee::salary));
Answer
Fine until two employees share a name, then IllegalStateException: Duplicate key Ana (attempted merging values 120 and 95).
The uncomfortable part is that this passes every test written against sample data with unique names, and fails on real data. If the key is genuinely unique, that is a domain invariant worth a comment; if it is not, the two-argument form is a latent outage.
Trap B:
Map<Boolean, List<Order>> split = orders.stream()
.collect(Collectors.groupingBy(Order::isPaid));
process(split.get(true));
Answer
NullPointerException the first time every order is unpaid — groupingBy only creates keys it saw, so get(true) returns null rather than an empty list.
partitioningBy always returns both keys, which is exactly what it is for. With groupingBy, use getOrDefault(true, List.of()).
Trap C:
Map<String, List<Employee>> byDept = staff.stream()
.collect(Collectors.groupingBy(Employee::dept));
byDept.get("eng").add(newHire);
Answer
This works today and is not guaranteed to. groupingBy promises neither the map type nor the list type, nor that either is mutable — it happens to give you a HashMap of ArrayList. Swap in a downstream collector such as toUnmodifiableList, or upgrade a JDK that changes the default, and the same line throws UnsupportedOperationException.
Say what you want: toList() explicitly if you need mutability, toUnmodifiableList() if you do not.
Common wrong answers
| Said in interviews | Reality |
|---|---|
| "They're basically the same, toMap is shorter." | Same result on clean data, completely different behaviour on duplicates and nulls. |
| "toMap keeps the last value for a duplicate key." | It throws. Keeping the last is what (a, b) -> b asks for explicitly. |
| "toMap accepts nulls because HashMap does." | It uses Map.merge, which rejects null values. |
| "groupingBy gives you a list per key and that's it." | The downstream collector is the whole feature. |
| "groupingBy with a boolean is the same as partitioningBy." | partitioningBy always returns both keys; groupingBy omits the empty side. |
| "The returned map keeps insertion order." | It is a HashMap. Pass a factory if order matters. |
Check Yourself
Q1. When is the two-argument toMap the right call?
Answer
Only when the key is genuinely unique and you would rather fail loudly than merge silently — because that is what it does. Otherwise use the three-argument form and state what a collision means.
Q2. Why does toMap throw on a null value when the underlying map accepts one?
Answer
Because it is implemented with Map.merge, whose contract rejects null values. The restriction comes from the collector, not the map, which is why the same key and value succeed through a plain put.
Q3. You group orders by a boolean and read get(true). What can go wrong, and what would you use instead?
Answer
groupingBy only creates keys it actually saw, so an all-false input leaves get(true) returning null. partitioningBy always returns both keys, or use getOrDefault(true, List.of()).
Practice
| Tier | Exercise | Time |
|---|---|---|
| Warm-up | Break toMap three ways | 10 min |
| Challenge | Replace five loops with collectors | 25 min |
| Production | The report that crashed on real data | 40 min |
| Interview | Full round replay | 10 min |
What changed, and when
Version boundaries are where follow-ups live. Know the change and the behaviour it replaced.
- Java 8LTS
Collectors arrived with groupingBy, partitioningBy, toMap and the downstream collectors that compose under them.
Before Java 8: A loop with a HashMap and computeIfAbsent, or the same thing written with containsKey and put — which is where the duplicate-key bug used to be written by hand instead of thrown.
- Java 10
toUnmodifiableList, toUnmodifiableSet and toUnmodifiableMap, so a collector can produce an immutable result directly.
Before Java 10: collect(toList()) then wrap in Collections.unmodifiableList, or Collectors.collectingAndThen with the wrapping applied at the end.
- Java 12
Collectors.teeing runs two collectors over the same stream in one pass and merges their results.
Before Java 12: Two passes over the source, or one custom collector written by hand — which is why min-and-max in a single traversal used to be a loop.
Practice ladder
Reading this page is not knowing it. Four tiers, ending in a real incident.
- Warm-up10 min
Break toMap three ways
One concept, guided. Near-impossible to fail.
- Challenge25 min
Replace five loops with collectors
Edge cases. You have to reason, and two valid fixes differ.
- Production incident40 min
The report that crashed on real data
A real incident: symptom first, cause hidden, tradeoff at the end.
- Interview replay10 min
Full round replay — collectors
Timed verbal replay with pass/fail criteria per follow-up.
Where this question goes next
- When is parallelStream() a mistake?
- stream exception handling — not written yet
- How does HashMap work internally?
Questions that lead here
Why does a stream with no terminal operation do nothing?
Intermediate operations only build a pipeline — they record what to do and return immediately. Nothing traverses the source until a terminal operation asks for a result, and then elements go through the whole pipeline one at a time rather than stage by stage.
Asked constantlyjunior1–10 yrs10 min readJava8When is parallelStream() a mistake?
It runs on one ForkJoinPool shared by the whole JVM, so a slow task in one place starves parallel work everywhere else — including anything a library does. Add the usual problems of shared mutable state and non-associative reductions, and the honest default is not to use it until you have measured.
Asked oftenintermediate2–12 yrs11 min readJava8Which sort does Arrays.sort use, and why does it depend on the type?
Dual-pivot quicksort for primitives, TimSort for objects. The split is not about speed — it is that objects can be distinguishable while comparing equal, so their sort has to be stable, and primitives cannot be, so theirs is free to sort in place.
Asked oftenintermediate1–12 yrs12 min readSearching and sorting
Every runnable example above was compiled and executed against openjdk 21.0.11 on this build, and its output diffed against what this page claims. Last updated 2026-08-28.