Definition
Collectors.toMap with a merge function aggregates a stream into a Map even when keys repeat.
- Key mapper – extracts the grouping key, here the account id.
- Value mapper – extracts the value to aggregate, here the transaction amount as
BigDecimal. - Merge function – a
BinaryOperatorinvoked when two values share a key;BigDecimal::addsums them. - IllegalStateException – thrown by the two-argument form on the first duplicate key, because it has no rule to combine values.
Supplying the merge function turns a collision from a crash into a running total.
At National Bank of India, Priya receives a pile of transaction slips, many for the same account. She wants one line per account showing the total balance. Without a rule, when a second slip for the same account appears she stops and raises her hand, because she cannot put two amounts in one box: that is the IllegalStateException. The merge function is the written instruction "add the new amount to the amount already recorded," which lets her keep a running total per account instead of freezing.
Understanding the Concepts
Why does a stream of transactions produce duplicate keys?
A transaction log naturally has many rows per account: a deposit, a withdrawal, a transfer, and so on. When you key a toMap by account id, the same id appears repeatedly. The two-argument toMap treats a repeated key as an error.
package com.bank.ledger;
import java.math.BigDecimal;
public class Transaction {
private final String accountId;
private final BigDecimal amount;
public Transaction(String accountId, BigDecimal amount) {
this.accountId = accountId;
this.amount = amount;
}
public String getAccountId() { return accountId; }
public BigDecimal getAmount() { return amount; }
}
What happens with the two-argument toMap?
It throws IllegalStateException on the first collision, naming both conflicting values. The design assumes keys are unique; a duplicate is treated as a bug.
package com.bank.ledger;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
public class DuplicateKeyCrash {
public static void main(String[] args) {
List<Transaction> txns = List.of(
new Transaction("ACC-1", new BigDecimal("1000.00")),
new Transaction("ACC-1", new BigDecimal("250.00"))); // same account
Map<String, BigDecimal> balances = txns.stream()
.collect(Collectors.toMap(Transaction::getAccountId, Transaction::getAmount));
System.out.println(balances);
}
}
Exception in thread "main" java.lang.IllegalStateException: Duplicate key ACC-1 (attempted merging values 1000.00 and 250.00)
How does the merge function resolve the collision?
Add a third argument, a BinaryOperator. It runs only on a duplicate key, receiving the value already stored and the incoming value, and returns the combined value. BigDecimal::add sums them into a running balance.
package com.bank.ledger;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
public class AccountBalances {
public static Map<String, BigDecimal> totals(List<Transaction> txns) {
return txns.stream()
.collect(Collectors.toMap(
Transaction::getAccountId,
Transaction::getAmount,
BigDecimal::add)); // sum on collision
}
}
{ACC-1=1250.00}
Why must money use BigDecimal and not double?
double cannot represent many decimal fractions exactly, so summing money as double accumulates rounding errors. BigDecimal stores decimal digits exactly and its add is precise.
// WRONG: double arithmetic drifts
double total = 0.0;
total += 0.1; total += 0.2;
System.out.println(total); // 0.30000000000000004
// CORRECT: BigDecimal is exact
BigDecimal exact = new BigDecimal("0.10").add(new BigDecimal("0.20"));
System.out.println(exact); // 0.30
How does toMap + merge compare with groupingBy?
Both can total balances per account. toMap with a merge aggregates values directly; groupingBy groups then reduces. Use groupingBy when the key naturally maps to many values and you want a clear grouping, and toMap when you want a single aggregate per key with explicit collision handling.
| Approach | Shape | Collision handling | Reads as |
|----------|-------|--------------------|----------|
| toMap(key, value, merge) | one value per key | explicit merge function | aggregate per key |
| groupingBy(key, reducing(...)) | grouped then reduced | implicit by grouping | group then combine |
| groupingBy(key, summingX(...)) | numeric sum per key | implicit | sum per key |
// groupingBy + reducing, equivalent total balance
Map<String, BigDecimal> byGrouping = txns.stream()
.collect(Collectors.groupingBy(
Transaction::getAccountId,
Collectors.reducing(BigDecimal.ZERO, Transaction::getAmount, BigDecimal::add)));
What are the common mistakes?
The first mistake is forgetting the merge function on data that clearly has repeated keys.
// WRONG: transaction log always repeats account ids, this crashes
Map<String, BigDecimal> balances = txns.stream()
.collect(Collectors.toMap(Transaction::getAccountId, Transaction::getAmount));
// CORRECT: sum duplicates with a merge function
Map<String, BigDecimal> balances = txns.stream()
.collect(Collectors.toMap(Transaction::getAccountId, Transaction::getAmount, BigDecimal::add));
The second mistake is summing money with double or Double::sum, which loses precision.
// WRONG: double loses precision on money
.collect(Collectors.toMap(Transaction::getAccountId, t -> t.getAmount().doubleValue(), Double::sum));
// CORRECT: stay in BigDecimal
.collect(Collectors.toMap(Transaction::getAccountId, Transaction::getAmount, BigDecimal::add));
What are the edge cases around nulls and empty streams?
toMap rejects null values, so a null amount throws NullPointerException. An empty transaction list yields an empty map, not a crash.
// Null amount: throws NullPointerException
List<Transaction> bad = List.of(new Transaction("ACC-1", null));
// Guard by filtering or defaulting the amount first
Map<String, BigDecimal> balances = bad.stream()
.filter(t -> t.getAmount() != null)
.collect(Collectors.toMap(Transaction::getAccountId, Transaction::getAmount, BigDecimal::add));
When should you use toMap with a merge versus a simple grouping?
Use toMap with a merge when you want exactly one aggregated value per key and want to express the combine rule explicitly. Use groupingBy with summingX or reducing when the mental model is "group these rows, then combine each group," especially for reporting with multiple downstream aggregations.
// One aggregate per account, explicit rule
Map<String, BigDecimal> balances = txns.stream()
.collect(Collectors.toMap(Transaction::getAccountId, Transaction::getAmount, BigDecimal::add));
What should a senior engineer know?
- Associativity for parallelism.
BigDecimal::addis associative, so the merge is correct in a parallel stream where partial maps are combined. - Scale consistency. Ensure all amounts share a scale (for example two decimal places) so totals print consistently; use
setScaleif sources differ. - Choosing the map type. Add a fourth argument
LinkedHashMap::neworTreeMap::newto control iteration or sorting of account ids. - Immutability. Wrap with
collectingAndThen(toMap(...), Collections::unmodifiableMap)so the balance snapshot cannot be mutated after it is produced.
What are common interview follow-up questions?
- "What exception does the two-argument
toMapthrow on duplicate account ids?" Answer:IllegalStateExceptionwith a "Duplicate key" message. - "How do you total balances instead of crashing?" Answer: supply
BigDecimal::addas the merge function. - "Why
BigDecimaland notdouble?" Answer:doubleintroduces rounding errors;BigDecimalis exact for money. - "When would you use
groupingByinstead?" Answer: when a key maps to many rows and you want grouping with one or more downstream aggregations.
Visual Overview
Stream<Transaction>
(ACC-1, 1000.00)
(ACC-2, 500.00)
(ACC-1, 250.00) <- duplicate key ACC-1
│
│ toMap(getAccountId, getAmount, MERGE)
▼
┌───────────────────────────────────────────────┐
│ Collectors.toMap │
│ put ACC-1 -> 1000.00 │
│ put ACC-2 -> 500.00 │
│ ACC-1 again? │
│ no MERGE ──▶ IllegalStateException(ACC-1) │
│ MERGE = BigDecimal::add │
│ existing 1000.00 + incoming 250.00 │
│ = 1250.00 │
└───────────────────────────────────────────────┘
│
▼
Map<String, BigDecimal>
{ ACC-1 = 1250.00, ACC-2 = 500.00 }
Why BigDecimal for money
double : 0.10 + 0.20 = 0.30000000000000004 (drift)
BigDecimal: 0.10 + 0.20 = 0.30 (exact)
Key Takeaway
When Priya builds a Map of account id to total balance from a transaction stream, the key insight is that a transaction log has many rows per account. The account id is therefore a duplicate key, and the two-argument Collectors.toMap treats the first duplicate as an error. It throws IllegalStateException naming both amounts, because it has no rule for putting two values in one box, the same way Priya raises her hand when she cannot file two slips under one account.
The fix is the three-argument toMap with a merge function. The merge runs only on a collision and receives the balance already recorded and the incoming amount. Returning existing.add(incoming), written as BigDecimal::add, turns the collision into a running total. Now each account appears once with the sum of all its transactions.
Money makes the type choice non-negotiable. double cannot represent many decimal fractions exactly, so summing balances as double drifts, and 0.10 + 0.20 prints as 0.30000000000000004. BigDecimal stores digits exactly and add is precise, which is why every amount stays a BigDecimal through the whole pipeline.
There is an alternative. groupingBy with reducing or summingX totals balances too, and reads more naturally when the mental model is "group the rows, then combine each group." Choose toMap with a merge when you want one explicit aggregate per key, and groupingBy when grouping is the dominant idea or you need several downstream aggregations.
In our banking work the discipline is clear: expect duplicate keys in any transaction feed, supply BigDecimal::add as the merge, and never let money touch double.
Remember one sentence: a transaction stream has duplicate account ids, so give toMap a BigDecimal::add merge function to sum balances instead of crashing with IllegalStateException.
---