How do Collectors.groupingBy and toMap differ in failure modes?

Asked oftenintermediate1–10 yrs10 min readJava 8LTSJava 10Java 12

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.
  • toMap throws IllegalStateException on a duplicate key. The message names the key and both values, which is the one good thing about the failure.
  • toMap throws NullPointerException on a null value — even though HashMap accepts nulls happily. It merges internally, and merge rejects null.
  • groupingBy does neither. Its value is a collection, so duplicates append and there is nothing to be null.
  • Fix toMap with 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 another groupingBy. 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:

Compiled and run on this build
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));
Output
  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:

Compiled and run on this build
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);
Output
  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:

Compiled and run on this build
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"))))));
Output
  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:

  • partitioningBy is groupingBy restricted to a boolean, and it always returns both keys — true and false — even when one side is empty. groupingBy with a boolean function does not; an empty side is simply absent, which is a different bug.
  • groupingBy nests. The downstream collector can be another groupingBy, which gives you Map<String, Map<String, List<Employee>>> from one pass.

The map you get is not the map you may have assumed

Compiled and run on this build
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());
Output
  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 wantUse
Group into listsgroupingBy(Entity::key)
Group and countgroupingBy(key, counting())
Group and sumgroupingBy(key, summingInt(Entity::amount))
Group and extract a fieldgroupingBy(key, mapping(Entity::name, toList()))
Group and take the bestgroupingBy(key, collectingAndThen(maxBy(cmp), unwrap))
Two-level indexgroupingBy(outer, groupingBy(inner))
Split on a booleanpartitioningBy(pred)always both keys
Index by a unique keytoMap(key, value) — throws on a duplicate
Index with a collision ruletoMap(key, value, (a, b) -> a)
A specific map typetoMap(k, v, merge, TreeMap::new) or groupingBy(k, TreeMap::new, downstream)
Count, sum, min, max, average in one passsummarizingInt(Entity::amount)
Two different collectors in one passteeing(a, b, merger) — since 12
An immutable resulttoUnmodifiableList/Set/Map — since 10
Join stringsjoining(", ", "[", "]")

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

CollectorDefault
toMap on a duplicate keythrows IllegalStateException
toMap with a null valuethrows NullPointerException — it uses Map.merge
groupingBy map typeHashMap — iteration order unspecified
groupingBy list typeunspecified; happens to be ArrayList, not promised
groupingBy with a booleanonly the keys it saw — an empty side is absent
partitioningByalways 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 interviewsReality
"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?

AnswerOnly 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?

AnswerBecause 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?

AnswergroupingBy 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

TierExerciseTime
Warm-upBreak toMap three ways10 min
ChallengeReplace five loops with collectors25 min
ProductionThe report that crashed on real data40 min
InterviewFull round replay10 min

What changed, and when

Version boundaries are where follow-ups live. Know the change and the behaviour it replaced.

  1. 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.

  2. 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.

  3. 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.

Where this question goes next

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 readJava8
  • When 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 readJava8
  • Which 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.