skip to content

What goes wrong when Collectors.toMap encounters duplicate keys, and how do you handle it correctly?

level: middleimportance: must knowfreq 58%

answer

  1. 2-arg toMap throws IllegalStateException on duplicate key
  2. 3-arg adds a merge (existing, incoming) -> winner
  3. (a,b)->a keep first; (a,b)->b keep last; Integer::sum combine
  4. 4-arg picks the map type (TreeMap/LinkedHashMap)
  5. Need all values per key? Use groupingBy, not a forced merge
  6. toMap rejects null values (Map.merge underneath)

basics

~10 s

The 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 s

Collectors.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
java
// 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

for a junior

Knows the 2-arg toMap throws on duplicate keys and that a 3-arg overload with a merge function exists.

for a middle

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.

for a senior

Anticipates collisions from real data, handles the null-value restriction, and selects toMap vs groupingBy vs toUnmodifiableMap deliberately.

for a principal

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)

context