Collectors.toMap throws IllegalStateException on duplicate keys. How do you resolve that, and what does the 3-argument merge-function overload do?
answer
- 3rd arg = BinaryOperator<V> mergeFunction (existing, new) -> keep
- Merge fires only on key collision
- (a,b)->b last-wins, (a,b)->a first-wins, Integer::sum to total
- Backed by Map.merge: merge returning null removes the key
- Want to keep all collisions? Use groupingBy, not a merge
basics
~20 sAdd a third argument: a merge function. When two elements produce the same key, the merge function takes the existing value and the new value and returns the one to keep. For example (a, b) -> b keeps the last value.
solid answer
~40 sThe two-argument toMap throws IllegalStateException on a key collision because it has no rule for combining values. The three-argument overload toMap(keyMapper, valueMapper, mergeFunction) supplies a BinaryOperator<V> that is invoked only when a key already exists: it receives the existing value and the incoming value and returns the value to keep. Common merges: (existing, replacement) -> replacement for last-wins, (a, b) -> a for first-wins, Integer::sum to total numeric values, or (a, b) -> a.combine(b) for domain merges. This is the idiomatic fix whenever keys are not guaranteed unique. Note the merge function is only called on collisions, so for unique keys it has no effect. Be aware that if the merge returns null, the entry is removed (Map.merge semantics), and a null value still triggers an NPE.
code
java · 8 lines// last-wins on duplicate names
Map<String, Person> byName = people.stream().collect(
Collectors.toMap(Person::name, Function.identity(),
(existing, replacement) -> replacement));
// sum amounts per category (collisions are aggregated)
Map<String, Integer> totalByCategory = items.stream().collect(
Collectors.toMap(Item::category, Item::amount, Integer::sum));go deeper
Knows you must add a third merge argument to avoid the duplicate-key exception, e.g. (a, b) -> b.
Explains that the BinaryOperator runs only on collisions, picks an appropriate policy (last/first/sum), and knows when groupingBy is the better tool.
Discusses Map.merge semantics (null removes the key, null values throw) and treats the 2-arg exception as an intentional fail-fast guard for unique-key invariants.
Establishes conventions for duplicate handling across a codebase, reasons about idempotent imports vs fail-fast invariants, and audits for silent data loss via careless merges.
## Why duplicate keys are a problem `Collectors.toMap(keyMapper, valueMapper)` builds a map one element at a time. If two elements compute the **same key**, the collector faces an ambiguity: which value should the map hold? With only two arguments it has no answer, so it **fails fast** with `IllegalStateException` ("Duplicate key X (attempted merging values ...)"). This protects you from silently losing data. ## The three-argument overload The fix is the overload that adds a **merge function**: ```java Map<K,V> toMap(Function<T,K> keyMapper, Function<T,V> valueMapper, BinaryOperator<V> mergeFunction) ``` A **`BinaryOperator<V>`** is a function taking *two* values of type `V` and returning one of type `V`. The merge function is invoked **only when a collision occurs**: it receives `(existingValue, newValue)` and returns the value the map should keep. ```java // last value wins Map<String, Person> byName = people.stream().collect( Collectors.toMap(Person::name, p -> p, (existing, replacement) -> replacement)); // first value wins ... (existing, replacement) -> existing); // sum numeric values Map<String, Integer> totalByCategory = items.stream().collect( Collectors.toMap(Item::category, Item::amount, Integer::sum)); // domain combine ... (a, b) -> a.mergeWith(b)); ``` ## Mental model Think of the map being filled left to right. The merge function only fires when the slot for a key is already occupied. For a key seen once, the merge function never runs. So for genuinely unique keys, supplying a merge function is harmless (it is simply never called) — many teams default to a merge for safety. ## Subtleties (Map.merge semantics) `toMap`'s collision handling is implemented on top of `Map.merge`, which has two edge behaviours worth knowing: 1. If the **merge function returns `null`**, the mapping for that key is **removed** from the map. 2. A **`null` value** anywhere (from `valueMapper` or as a merge result that is stored) is problematic — `HashMap.merge` throws `NullPointerException` on a null value because it cannot distinguish "absent" from "present-but-null". ## Choosing a merge policy - **Last-wins** `(a, b) -> b`: tolerant import of duplicates, newest record wins. - **First-wins** `(a, b) -> a`: keep the earliest, ignore later dups. - **Reduce** `Integer::sum`, `BigDecimal::add`, `Math::max`: aggregate. - **Fail loudly**: if duplicates are truly a bug, *omit* the merge function on purpose so the 2-arg form throws — the exception is a feature, not a defect. ## When grouping is the real intent If you actually want to *keep all* colliding values (e.g. `Map<Dept, List<Person>>`), do not write a merge that concatenates lists element by element — use `Collectors.groupingBy(Person::dept)` instead, which is built for many-to-one keys.
- If you supply a merge function but all keys turn out to be unique, does it change the result?No. The merge function only runs on key collisions, so with unique keys it is never invoked and the map is identical to the 2-arg result.
- How would you keep all values that share a key instead of merging to one?Use Collectors.groupingBy with a downstream collector (e.g. groupingBy(keyMapper, mapping(valueMapper, toList()))) — toMap is for one value per key.
saying these in an interview costs you the question
- Thinking the merge function runs for every element rather than only on collisions
- Using a merge to concatenate when groupingBy is the right tool
- Forgetting that returning null from the merge removes the entry
- Believing the 2-arg IllegalStateException is always a bug to suppress — sometimes failing loudly is correct