SSerguey Asael Shinder
Java coding notes: the JVM, and writing software that lasts

Serguey Asael Shinder: Collectors.toMap throws on duplicate keys, and not where you are looking

· by Serguey Asael Shinder / Serguey Shinder

A stream collected into a map is one of the most common lines in modern Java, and the two-argument form has a failure mode that surprises people the first time:

Map<String, Person> byEmail = people.stream()
        .collect(Collectors.toMap(Person::email, p -> p));

If two people share an email, this throws.

The javadoc for Collectors.toMap(Function, Function)) states it directly: if the mapped keys contain duplicates, compared with Object.equals(Object), an IllegalStateException is thrown when the collection operation is performed, and where keys might collide you should use the three-argument overload instead.

Two details in that sentence do the work.

"According to Object.equals". The collision is decided by the key's equality contract, not by anything the collector knows. A key type with a loose equals — a case-insensitive wrapper, a BigDecimal where 1.0 and 1.00 are famously not equal — decides the behaviour here, and the collector inherits whatever that type decided.

"When the collection operation is performed". The exception does not come from building the collector; it comes from running the terminal operation. So the stack trace points at .collect(...), the collector was constructed somewhere else entirely, and in a pipeline with several stages the line that is wrong and the line that throws are different lines.

Serguey Asael Shinder: Collectors.toMap throws on duplicate keys, and not where you are looking
Collectors.toMap throws on duplicate keys, and not where you are looking — Serguey Asael Shinder

The fix the javadoc names is the merge function, which turns an error into a decision:

Map<String, Person> byEmail = people.stream()
        .collect(Collectors.toMap(
                Person::email,
                p -> p,
                (first, second) -> first));   // keep the first, or merge, or throw on purpose

Writing (a, b) -> a is not a workaround. It is a statement that duplicates are expected and that the earlier one wins — which is the thing the two-argument form refuses to guess.

One more line from the same page is worth carrying: the returned map comes with no guarantees on type, mutability, serializability or thread-safety. If the code downstream assumes a HashMap, or mutates the result, that assumption is not supported by the contract — there is a four-argument overload taking a map factory for exactly that case.

A useful contrast sits a few entries down the same javadoc. toUnmodifiableList() says it disallows null values and throws NullPointerException when given one. That is a different posture from toMap's: both are fail-fast, but one is documented against nulls and the other against key collisions, and a reader who assumes collectors behave uniformly about nulls will be wrong about one of them.

⚠️ There is no JDK installed on the machine this was written on, so the snippets above are written to compile rather than verified by running them. Every behavioural claim is taken from the linked javadoc, which is the authority worth checking against the JDK version you actually ship.