What goes wrong when Collectors.toMap encounters duplicate keys, and how do you handle it correctly?
answer
- 2-arg toMap throws IllegalStateException on duplicate key
- 3-arg adds a merge (existing, incoming) -> winner
- (a,b)->a keep first; (a,b)->b keep last; Integer::sum combine
- 4-arg picks the map type (TreeMap/LinkedHashMap)
- Need all values per key? Use groupingBy, not a forced merge
- toMap rejects null values (Map.merge underneath)
basics
~10 sThe two-argument Collectors.toMap throws IllegalStateException ('Duplicate key') when two elements produce the same key. Use the three-argument version with a merge function to decide which value wins.
solid answer
~50 sCollectors.toMap(keyFn, valueFn) assumes every key is unique. If two stream elements map to the same key, it throws IllegalStateException with a 'Duplicate key' message — and the message can even mention the conflicting values, not the key, which surprises people. The fix is the three-argument overload toMap(keyFn, valueFn, mergeFunction): the merge function receives the existing and incoming values and returns the one to keep (e.g. (a, b) -> a to keep the first, (a, b) -> b for the last, or Integer::sum to combine). There is also a four-argument form that takes a Map supplier when you need a specific map type like TreeMap or LinkedHashMap. A related trap: the merge function is also required when a value can be null, since toMap rejects null values. If you actually want to group multiple values under one key, reach for groupingBy instead of forcing a merge.
code
java · 21 lines// Throws on duplicate name:
// people.stream().collect(Collectors.toMap(Person::name, p -> p));
// Resolve collisions explicitly (keep first):
Map<String, Person> byName = people.stream()
.collect(Collectors.toMap(
Person::name,
p -> p,
(existing, incoming) -> existing)); // merge function
// Combine values + pick map type:
Map<String, Integer> spend = orders.stream()
.collect(Collectors.toMap(
Order::customerId,
Order::total,
Integer::sum,
TreeMap::new));
// Need every colliding element? Use groupingBy instead:
Map<String, List<Person>> grouped = people.stream()
.collect(Collectors.groupingBy(Person::name));go deeper
Knows the 2-arg toMap throws on duplicate keys and that a 3-arg overload with a merge function exists.
Chooses an appropriate merge function (first/last/sum), knows the 4-arg map-supplier form, and reaches for groupingBy when one key maps to many.
Anticipates collisions from real data, handles the null-value restriction, and selects toMap vs groupingBy vs toUnmodifiableMap deliberately.
Establishes patterns for deterministic collision handling (avoid order-dependent winners on parallel/unordered streams), and reviews for hidden duplicate-key risks in data-driven keys.
## Setup - **`Collectors.toMap`**: a collector that turns a stream into a `Map`. You supply a **key function** (how to derive each entry's key) and a **value function** (how to derive each entry's value). - **Collision / duplicate key**: two different stream elements produce the **same** key. A `Map` cannot hold two entries with one key, so something must decide what happens. ## The pitfall The **two-argument** overload assumes keys are unique: ```java Map<String, Person> byName = people.stream() .collect(Collectors.toMap(Person::name, p -> p)); ``` If two people share a name, this throws at runtime: ``` java.lang.IllegalStateException: Duplicate key Alice (attempted merging values Person[..] and Person[..]) ``` Note two gotchas: 1. It's a **runtime** exception — the compiler can't warn you; you discover it only when real data collides. 2. The message historically prints the **values**, which confuses people looking for the key. ## The fix: a merge function (3-arg overload) The **three-argument** `toMap(keyFn, valueFn, mergeFunction)` resolves collisions. The merge function is a `BinaryOperator<V>`: it gets the **existing** value and the **incoming** value and returns the one to store. ```java // keep the first occurrence toMap(Person::name, p -> p, (existing, incoming) -> existing) // keep the last occurrence toMap(Person::name, p -> p, (existing, incoming) -> incoming) // combine numeric values toMap(Order::customerId, Order::total, Integer::sum) ``` ## The 4-arg overload: choosing the map type A **four-argument** form adds a `Supplier` for the backing map, so you can get ordering or sorting: ```java toMap(Person::name, p -> p, (a, b) -> a, LinkedHashMap::new); // preserves encounter order toMap(Person::name, p -> p, (a, b) -> a, TreeMap::new); // sorted by key ``` ## The null trap `toMap` **rejects null values** — a null value triggers a `NullPointerException` (because internally it uses `Map.merge`, which forbids nulls). If your value function can yield null, either filter those out first or restructure. The merge overload does not save you from a null *value*; it only resolves *key* collisions. ## When merging is the wrong tool: groupingBy If you actually want **all** colliding elements, not one winner, use `groupingBy`: ```java Map<String, List<Person>> byName = people.stream().collect(Collectors.groupingBy(Person::name)); ``` `groupingBy` is designed for one-key-to-many; `toMap` is one-key-to-one. Forcing a merge function onto `toMap` when you really need a list is a smell — reach for `groupingBy`. ## Summary - 2-arg `toMap` → throws on duplicate keys. - 3-arg `toMap` → you decide the winner via a merge function. - 4-arg `toMap` → also choose the map implementation. - Need every colliding value? Use `groupingBy`. - `toMap` forbids null values regardless of overload.
- How do you make toMap keep the last element on a collision instead of the first?Pass the merge function (existing, incoming) -> incoming. To keep the first, use (existing, incoming) -> existing.
- Your value function sometimes returns null and toMap throws NPE — why, and what do you do?toMap uses Map.merge internally, which forbids null values. Filter out null-producing elements first, substitute a sentinel/Optional, or use a different collector (e.g. groupingBy with a downstream that tolerates the data).
saying these in an interview costs you the question
- Assuming toMap silently overwrites on duplicate keys (it throws)
- Using toMap with a merge function when groupingBy is the right tool
- Forgetting toMap forbids null values
- Thinking the merge function handles null values (it handles key collisions)