Definition
Collectors.toMap is a collector that reduces a stream into a Map.
- Key mapper – a
Functionthat extracts the map key from each element, such asEmployee::getId. - Value mapper – a
Functionthat extracts the map value, such asFunction.identity()to store the employee itself. - Merge function – a
BinaryOperatorcalled only when two elements map to the same key; it receives both values and returns the one to keep. - Map supplier – an optional fourth argument that chooses the concrete
Maptype, such asLinkedHashMap::new.
Without a merge function, a duplicate key is treated as a programming error and toMap throws immediately.
At National Bank of India, Rajesh is handed a stack of employee records and told to file one folder per employee id. The id is the label on the folder (the key mapper) and the record is what goes inside (the value mapper). If two records carry the same id, Rajesh cannot put both in one folder, so he follows a written rule: keep the record with the most recent join date. That written rule is the merge function. Without it, Rajesh would freeze and refuse to file anything, which is exactly what IllegalStateException does.
Understanding the Concepts
What does Collectors.toMap do?
Collectors.toMap is a terminal collector that turns each stream element into a key-value pair and assembles those pairs into a Map. You give it two functions: one that produces the key and one that produces the value.
For our banking scenario, Priya in HR wants a Map so she can look up any employee instantly by id.
package com.bank.hr;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
public class EmployeeLookup {
public static Map<Long, Employee> index(List<Employee> employees) {
return employees.stream()
.collect(Collectors.toMap(Employee::getId, Function.identity()));
}
}
- The first argument
Employee::getIdis the key mapper. - The second argument
Function.identity()is the value mapper (store the employee itself).
How does toMap work internally?
For each element the collector calls the key mapper and the value mapper, then puts the pair into an internal HashMap using merge. When a key already exists, it must decide what to do. In the two-argument form the supplied merge behavior throws, which is how you get the duplicate-key failure.
// Simplified mental model of the two-argument toMap
Map<Long, Employee> map = new HashMap<>();
for (Employee e : employees) {
Long key = e.getId();
Employee value = e;
Employee existing = map.putIfAbsent(key, value);
if (existing != null) {
throw new IllegalStateException("Duplicate key " + key);
}
}
Why does the two-argument form throw on duplicates?
The designers treat a duplicate key as a bug by default. If you expected ids to be unique and two records collide, silently dropping one would hide a data problem. So the two-argument form fails loudly.
package com.bank.hr;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
public class DuplicateKeyDemo {
public static void main(String[] args) {
List<Employee> employees = List.of(
new Employee(101L, "Rajesh"),
new Employee(101L, "Rajesh Kumar") // same id
);
Map<Long, Employee> map = employees.stream()
.collect(Collectors.toMap(Employee::getId, Function.identity()));
System.out.println(map);
}
}
Exception in thread "main" java.lang.IllegalStateException: Duplicate key 101 (attempted merging values Employee{id=101, name=Rajesh} and Employee{id=101, name=Rajesh Kumar})
How do you supply a merge function?
The three-argument overload adds a BinaryOperator. It is called only on a collision and receives the value already in the map (existing) and the value from the current element (replacement). Whatever it returns becomes the stored value.
package com.bank.hr;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
public class MergeKeepFirst {
public static Map<Long, Employee> index(List<Employee> employees) {
return employees.stream()
.collect(Collectors.toMap(
Employee::getId,
Function.identity(),
(existing, replacement) -> existing // keep the first seen
));
}
}
{101=Employee{id=101, name=Rajesh}}
How do you keep the last value instead of the first?
Return the replacement from the merge function. This mirrors the behavior of a plain map.put, where the later write wins.
// Keep the most recently encountered employee for a given id
Map<Long, Employee> map = employees.stream()
.collect(Collectors.toMap(
Employee::getId,
Function.identity(),
(existing, replacement) -> replacement
));
A more meaningful banking rule is to keep the record with the later join date rather than relying on stream order:
Map<Long, Employee> map = employees.stream()
.collect(Collectors.toMap(
Employee::getId,
Function.identity(),
(a, b) -> a.getJoinDate().isAfter(b.getJoinDate()) ? a : b
));
How do the overloads compare?
| Overload | Signature | On duplicate key | Map type |
|----------|-----------|------------------|----------|
| Two-arg | toMap(key, value) | Throws IllegalStateException | HashMap |
| Three-arg | toMap(key, value, merge) | Calls merge function | HashMap |
| Four-arg | toMap(key, value, merge, supplier) | Calls merge function | Your supplier (e.g. LinkedHashMap) |
// Four-arg form: merge plus a predictable iteration order
Map<Long, Employee> ordered = employees.stream()
.collect(Collectors.toMap(
Employee::getId,
Function.identity(),
(a, b) -> b,
java.util.LinkedHashMap::new
));
What are the common mistakes?
The most common mistake is using the two-argument form on data that can legitimately contain duplicate keys, then discovering the crash in production.
// WRONG: assumes ids are unique, crashes on the first collision
Map<Long, Employee> map = employees.stream()
.collect(Collectors.toMap(Employee::getId, Function.identity()));
// CORRECT: state the collision policy explicitly
Map<Long, Employee> map = employees.stream()
.collect(Collectors.toMap(
Employee::getId,
Function.identity(),
(existing, replacement) -> existing));
The second common mistake is returning null from the value mapper or the merge function. toMap is backed by Map.merge, which rejects null values.
// WRONG: value mapper can return null, throws NullPointerException
.collect(Collectors.toMap(Employee::getId, Employee::getManager));
// CORRECT: map to a non-null placeholder, or filter nulls out first
.collect(Collectors.toMap(
Employee::getId,
e -> e.getManager() == null ? UNKNOWN_MANAGER : e.getManager(),
(a, b) -> a));
What are the edge cases around nulls and ordering?
toMap makes no promise about iteration order because it defaults to HashMap. It also forbids null values. A null key is allowed by HashMap itself but fragile, so avoid key mappers that can return null.
package com.bank.hr;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
public class NullValueDemo {
public static void main(String[] args) {
List<Employee> employees = List.of(new Employee(101L, "Rajesh"));
// getManager() returns null, so the value mapper yields null
Map<Long, Employee> map = employees.stream()
.collect(Collectors.toMap(Employee::getId, Employee::getManager));
System.out.println(map);
}
}
Exception in thread "main" java.lang.NullPointerException
When should you use a merge function versus groupingBy?
Use toMap with a merge function when each key should map to exactly one value and you want a rule to pick the winner. Use groupingBy when a key naturally maps to many values and you want to keep all of them in a list.
// toMap + merge: one winning employee per id
Map<Long, Employee> latest = employees.stream()
.collect(Collectors.toMap(Employee::getId, Function.identity(), (a, b) -> b));
// groupingBy: all employees sharing a department
Map<String, List<Employee>> byDept = employees.stream()
.collect(Collectors.groupingBy(Employee::getDepartment));
What should a senior engineer know?
- Parallel safety. In a parallel stream,
toMapmerges partial maps. The merge function must be associative so the result does not depend on how the stream was split. - Choosing the map type. The four-argument form lets you pass
TreeMap::newfor sorted keys orLinkedHashMap::newfor insertion order. The merge function is still mandatory in that overload. - Immutability. Wrap the result with
Collectors.collectingAndThen(toMap(...), Collections::unmodifiableMap)when the lookup should not be modified after creation. - Failure as a feature. Keeping the two-argument form where ids truly must be unique is a deliberate choice: the crash surfaces data-quality bugs early instead of letting one silently overwrite another.
What are common interview follow-up questions?
- "What exception does the two-argument
toMapthrow on a duplicate key, and what message does it carry?" Answer:IllegalStateExceptionwith a "Duplicate key" message that names both values. - "How do you keep the first versus the last value?" Answer:
(existing, replacement) -> existingkeeps the first;(a, b) -> bkeeps the last. - "Why does
toMapreject null values?" Answer: it is implemented overMap.merge, which throwsNullPointerExceptionon a null value. - "How do you make the result ordered or immutable?" Answer: use the four-argument overload with
LinkedHashMap::new, and wrap withcollectingAndThenplusunmodifiableMap.
Visual Overview
List<Employee> (two records share id 101)
│
│ stream().collect(toMap(getId, identity, merge))
▼
┌────────────────────────────────────────────┐
│ Collectors.toMap │
│ │
│ for each Employee e: │
│ key = keyMapper(e) e.getId() │
│ value = valueMapper(e) e │
│ │
│ key already present? │
│ │ no │ yes │
│ ▼ ▼ │
│ put(key,value) merge(existing, value) │
│ │ │
│ ▼ │
│ keep the returned value │
└────────────────────────────────────────────┘
│
▼
Map<Long, Employee>
Duplicate key 101
│
├── two-arg toMap ──▶ IllegalStateException ("Duplicate key 101")
│
└── three-arg toMap ──▶ merge(existing, replacement)
│
┌───────────────┼────────────────┐
▼ ▼ ▼
(e,r) -> e (e,r) -> r (a,b) -> later joinDate
keep first keep last keep most recent
Key Takeaway
When Priya asks for a Map so HR can look up any employee by id, Collectors.toMap is the direct tool. You hand it two functions: Employee::getId to produce the key and Function.identity() to store the employee as the value. For clean data where every id is unique, that two-argument form is all you need.
The moment two employees share an id, the two-argument form stops and throws IllegalStateException with a "Duplicate key" message. This is not a flaw; it is the collector refusing to silently discard data. It is the same instinct Rajesh had when he was told to file two records under one folder label and could not decide which one to keep.
The fix is the three-argument overload, which adds a merge function. That function runs only on a collision and receives the value already stored and the value from the new element. Whatever you return is the value that survives. (existing, replacement) -> existing keeps the first record seen, (a, b) -> b keeps the last, and a rule like comparing join dates keeps the most recent and meaningful record.
Two traps remain. The value mapper and the merge function must never return null, because toMap is built on Map.merge, which rejects nulls with a NullPointerException. And the default result is an unordered HashMap, so reach for the four-argument overload with LinkedHashMap::new or TreeMap::new when order matters, and wrap with collectingAndThen plus unmodifiableMap when the lookup should stay read-only.
In our banking work the lesson is simple: decide your collision policy before you collect, write it as the merge function, and let the stream enforce it for you.
Remember one sentence: toMap without a merge function crashes on duplicate keys, so supply a merge function to choose which value wins.
---