Collectors.toMap Duplicate Key Trap: The Aggregator That Throws on Your Second Sale2026-08-18
This method is supposed to build a map of customer ID to their total sales amount. It works flawlessly in unit tests where every customer appears once, then blows up in production the first time someone places a second order.
import java.math.BigDecimal;
import java.util.*;
import java.util.stream.Collectors;
record Sale(long customerId, BigDecimal amount) {}
public class SalesReport {
// Build customerId -> total sales amount
static Map<Long, BigDecimal> totalsByCustomer(List<Sale> sales) {
return sales.stream()
.collect(Collectors.toMap(
Sale::customerId,
Sale::amount
));
}
public static void main(String[] args) {
var sales = List.of(
new Sale(101L, new BigDecimal("49.99")),
new Sale(102L, new BigDecimal("19.99")),
new Sale(101L, new BigDecimal("29.99")) // repeat customer
);
totalsByCustomer(sales).forEach((id, total) ->
System.out.println(id + ": " + total));
}
}
Every code review has nodded this through. It compiles, the types line up, the tests pass. But the third element is a repeat customer, and the program dies with:
Exception in thread "main" java.lang.IllegalStateException:
Duplicate key 101 (attempted merging values 49.99 and 29.99)
The name toMap reads like "turn this stream into a map", so developers assume it does the sensible thing on collisions — either merge, overwrite, or at least return the last write. It does none of these. The two-argument overload of Collectors.toMap is defined to throw IllegalStateException whenever two elements produce the same key.
Under the hood, toMap(k, v) delegates to toMap(k, v, throwingMerger()), where throwingMerger is a private static method that unconditionally throws. The Javadoc mentions this in a paragraph most people skim past, and the compiler cannot warn you because a stream of unique-keyed elements is a perfectly valid input — the bug is data-dependent.
The trap is especially cruel because the "obvious" test case — one element per key — masks it entirely. It only fires when a real workload includes duplicates, which is precisely the case aggregation code exists to handle.
There's a second, related trap lurking in the same API: even the three-argument overload will throw NullPointerException if any value is null, because the underlying HashMap.merge forbids nulls. So toMap with a nullable value function is a landmine too.
Use the three-argument form and supply an explicit merge function that expresses your intent:
static Map<Long, BigDecimal> totalsByCustomer(List<Sale> sales) {
return sales.stream()
.collect(Collectors.toMap(
Sale::customerId,
Sale::amount,
BigDecimal::add // merge duplicates by summing
));
}
For "last write wins" semantics use (a, b) -> b; for "first write wins" use (a, b) -> a. If you're grouping rather than summing a single field, reach for Collectors.groupingBy instead — it's designed for the collision case and reads more clearly:
sales.stream().collect(Collectors.groupingBy(
Sale::customerId,
Collectors.reducing(BigDecimal.ZERO, Sale::amount, BigDecimal::add)
));
Whichever you pick, treat every toMap call as suspect until you've asked: can two elements ever produce the same key? If the answer isn't a confident "no," the two-argument form is a bug waiting for its first duplicate.
Collectors.toMap(k, v) throws on duplicate keys and nulls — always use the three-argument form with an explicit merge function, or switch to groupingBy when aggregating.
