Back to Stream: map & Transform
Q2Question 2 of 34

Collect employees into a Map<Long, Employee> keyed by id using Collectors.toMap with a merge function

Key Concepts
  • `Collectors.toMap(keyMapper, valueMapper)` turns a stream into a Map by deriving a key and a value from each element.
  • When two elements produce the same key, the two-argument form throws `IllegalStateException` with a "Duplicate key" message.
  • The three-argument form `toMap(key, value, mergeFunction)` resolves collisions by passing the existing and new value to a `BinaryOperator`.
  • The merge function returns the value to keep: `(existing, replacement) -> existing` keeps the first, `(a, b) -> b` keeps the last.
  • For employees keyed by id, the merge function lets you pick the more complete or more recent record instead of crashing.
  • `toMap` makes no ordering or null guarantees; a null value from the value mapper also throws `NullPointerException`.

Definition

`Collectors.toMap(Employee::getId, e -> e)` builds a `Map<Long, Employee>`, and the third argument, a merge function, decides which employee survives when two share the same id.

Collectors.toMap is a collector that reduces a stream into a Map.

  • Key mapper – a Function that extracts the map key from each element, such as Employee::getId.
  • Value mapper – a Function that extracts the map value, such as Function.identity() to store the employee itself.
  • Merge function – a BinaryOperator called 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 Map type, such as LinkedHashMap::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.

JAVA
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::getId is 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.

JAVA
// 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.

JAVA
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);
    }
}
TEXT
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.

JAVA
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
            ));
    }
}
TEXT
{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.

JAVA
// 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:

JAVA
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) |

JAVA
// 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.

JAVA
// WRONG: assumes ids are unique, crashes on the first collision
Map<Long, Employee> map = employees.stream()
    .collect(Collectors.toMap(Employee::getId, Function.identity()));
JAVA
// 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.

JAVA
// 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.

JAVA
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);
    }
}
TEXT
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.

JAVA
// 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, toMap merges 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::new for sorted keys or LinkedHashMap::new for 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 toMap throw on a duplicate key, and what message does it carry?" Answer: IllegalStateException with a "Duplicate key" message that names both values.
  • "How do you keep the first versus the last value?" Answer: (existing, replacement) -> existing keeps the first; (a, b) -> b keeps the last.
  • "Why does toMap reject null values?" Answer: it is implemented over Map.merge, which throws NullPointerException on a null value.
  • "How do you make the result ordered or immutable?" Answer: use the four-argument overload with LinkedHashMap::new, and wrap with collectingAndThen plus unmodifiableMap.

Visual Overview

DIAGRAM
  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>
DIAGRAM
  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.

---

Share
Use ← → arrow keys to navigate