Java's Collectors.toMap Duplicate Key Trap: The Aggregator That Throws on Your Second Sale

2026-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 Bug

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.

The Fix

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.

Key Takeaway: 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.

All newsletters