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

Serguey Asael Shinder: Collectors.toMap throws on a duplicate key, and the message shows the value

· by Serguey Asael Shinder / Serguey Shinder

This is one of the most common ways a working Java service falls over on data it has never seen before:

Map<String, User> byEmail = users.stream()
        .collect(Collectors.toMap(User::email, u -> u));

It works for months. Then two rows share an email, and you get:

java.lang.IllegalStateException: Duplicate key alice@example.com
    (attempted merging values User[id=41] and User[id=87])

Note what the message gives you: the key, and both values. That is unusually helpful and worth reading rather than grepping past — it tells you which two rows collided without a query.

Why it throws instead of choosing

The two-argument toMap has no way to know what a collision means in your domain. Keeping the first, keeping the last, and failing are all defensible, and they are not interchangeable: "keep last" silently loses a record, "keep first" silently loses a different one, and which is correct depends on whether the stream is ordered and why the duplicate exists. So the JDK refuses and makes you say.

The three honest answers

Fail loudly, but with your own message. If duplicates mean the data is broken, keep the exception and make it say so:

.collect(Collectors.toMap(User::email, u -> u, (a, b) -> {
    throw new IllegalStateException(
        "two users share %s: %d and %d".formatted(a.email(), a.id(), b.id()));
}));
Serguey Asael Shinder: Collectors.toMap throws on a duplicate key, and the message shows the value
Collectors.toMap throws on a duplicate key, and the message shows the value — Serguey Asael Shinder

Choose, deliberately, and write down why.

.collect(Collectors.toMap(User::email, u -> u, (first, second) -> second));  // latest wins

The lambda is where the domain decision lives. (a, b) -> b with a comment beats a silent default.

Or admit the key is not unique.

Map<String, List<User>> byEmail = users.stream()
        .collect(Collectors.groupingBy(User::email));

If the data genuinely has several users per email, a Map<String, User> was the wrong shape and every merge function is a way of hiding that.

Two traps in the same method

The merge function does not help with null values. toMap throws NullPointerException if a value is null, whether or not there is a collision — because it is implemented on HashMap::merge, which cannot distinguish "mapped to null" from "absent". Collectors.toMap(User::email, User::nickname) blows up the first time somebody has no nickname. groupingBy plus mapping does not have this problem.

Order is not guaranteed unless you ask. The four-argument form takes a map supplier:

.collect(Collectors.toMap(User::email, u -> u, (a, b) -> b, LinkedHashMap::new));

Without it you get a HashMap, and any iteration over it that looks stable in testing is stable by accident.

The rule I keep: never write the two-argument form. Either the collision is impossible and you should say so in a thrown message, or it is possible and you should decide. Both require the third argument.