Java
NullPointerException
Collectors.toMap
Programming Errors
Data Handling

NullPointerException in Collectors.toMap with null entry values

Interview Questions practice on Codemia

Over 8,000 real interview questions from top companies, searchable by company and role.

Browse interview questions

Collectors.toMap throws a NullPointerException when the value-mapping function returns null. This surprises most developers because HashMap.put accepts null values without complaint. The root cause is that toMap internally calls Map.merge, which explicitly rejects null values. The fix depends on what you want: filter nulls out, substitute a default, or use a manual collector that permits nulls.

Why toMap Rejects Null Values

The two-argument form of Collectors.toMap looks straightforward:

java
Map<String, String> result = people.stream()
    .collect(Collectors.toMap(Person::getName, Person::getEmail));

But if any person's email is null, this throws:

text
1java.lang.NullPointerException
2    at java.base/java.util.Objects.requireNonNull(Objects.java:14)
3    at java.base/java.util.Map.merge(Map.java:1153)
4    at java.base/java.util.stream.Collectors.lambda$toMap$68(Collectors.java:1658)

The stack trace reveals the mechanism: Collectors.toMap calls Map.merge under the hood. The merge method's contract explicitly requires non-null values because it needs to distinguish between "this key maps to null" and "this key is absent" when applying the merge function.

Here is a minimal reproduction:

java
1import java.util.*;
2import java.util.stream.Collectors;
3
4public class ToMapNullDemo {
5    record Person(String name, String email) {}
6
7    public static void main(String[] args) {
8        List<Person> people = List.of(
9            new Person("Alice", "[email protected]"),
10            new Person("Bob", null),          // null value triggers NPE
11            new Person("Carol", "[email protected]")
12        );
13
14        // This throws NullPointerException
15        Map<String, String> emails = people.stream()
16            .collect(Collectors.toMap(Person::name, Person::email));
17    }
18}

Solution 1: Filter Out Null Values

When entries with null values should simply be excluded from the result, filtering is the cleanest and most readable approach:

java
Map<String, String> emails = people.stream()
    .filter(p -> p.email() != null)
    .collect(Collectors.toMap(Person::name, Person::email));

Result: {[email protected], [email protected]}

Bob is excluded entirely. This is appropriate when downstream code treats a missing key the same as a null value, which is the case in most map lookups.

You can chain multiple filters when both keys and values might be null:

java
Map<String, String> emails = people.stream()
    .filter(p -> p.name() != null && p.email() != null)
    .collect(Collectors.toMap(Person::name, Person::email));

Solution 2: Substitute a Default Value

When the resulting map must contain every key, replace nulls with a sentinel value before collecting:

java
1Map<String, String> emails = people.stream()
2    .collect(Collectors.toMap(
3        Person::name,
4        p -> p.email() != null ? p.email() : ""
5    ));

Result: {[email protected], Bob=, [email protected]}

Choose the default carefully. An empty string, a domain-specific placeholder like "not_provided", or Optional.empty() (if you switch to Map<String, Optional<String>>) each communicate different things to downstream consumers.

java
1// Using a descriptive sentinel
2Map<String, String> emails = people.stream()
3    .collect(Collectors.toMap(
4        Person::name,
5        p -> Objects.requireNonNullElse(p.email(), "N/A")
6    ));

Objects.requireNonNullElse (Java 9+) is a concise alternative to the ternary expression.

Solution 3: Manual Collector for Null-Permissive Maps

When you genuinely need a map that stores null values (not a sentinel, but actual null), Collectors.toMap is the wrong tool. Use the three-argument collect method with explicit accumulation:

java
1Map<String, String> emails = people.stream().collect(
2    HashMap::new,
3    (map, p) -> map.put(p.name(), p.email()),
4    HashMap::putAll
5);

Result: {[email protected], Bob=null, [email protected]}

This works because HashMap.put accepts null values. The three arguments are:

  1. Supplier: Creates the result container (HashMap::new).
  2. Accumulator: Adds each element to the container.
  3. Combiner: Merges two containers during parallel execution.

Extracting a reusable utility

If this pattern appears frequently in your codebase, extract it:

java
1public static <T, K, V> Collector<T, ?, Map<K, V>> toNullableMap(
2    Function<T, K> keyMapper,
3    Function<T, V> valueMapper
4) {
5    return Collector.of(
6        HashMap::new,
7        (map, element) -> map.put(keyMapper.apply(element), valueMapper.apply(element)),
8        (left, right) -> { left.putAll(right); return left; }
9    );
10}
11
12// Usage
13Map<String, String> emails = people.stream()
14    .collect(toNullableMap(Person::name, Person::email));

Handling Duplicate Keys: A Separate Problem

Even after fixing the null-value issue, toMap can throw IllegalStateException if two elements produce the same key:

text
java.lang.IllegalStateException: Duplicate key Alice

The three-argument form of toMap adds a merge function to resolve conflicts:

java
1Map<String, String> emails = people.stream()
2    .filter(p -> p.email() != null)
3    .collect(Collectors.toMap(
4        Person::name,
5        Person::email,
6        (existing, replacement) -> existing  // keep the first value
7    ));

Common merge strategies:

java
1// Keep the first value
2(existing, replacement) -> existing
3
4// Keep the last value
5(existing, replacement) -> replacement
6
7// Concatenate
8(existing, replacement) -> existing + ", " + replacement
9
10// Throw on conflict (default behavior, made explicit)
11(existing, replacement) -> {
12    throw new IllegalStateException("Duplicate key");
13}

Null handling and duplicate-key handling are independent concerns. You may need to address both in the same stream pipeline, but they require different fixes.

toMap vs groupingBy: When to Switch

If multiple elements can share the same key and you want to keep all values, Collectors.groupingBy is often a better fit than toMap with a merge function:

java
1// toMap with merge: last writer wins (lossy)
2Map<String, String> byDept = people.stream()
3    .filter(p -> p.email() != null)
4    .collect(Collectors.toMap(
5        Person::department,
6        Person::email,
7        (a, b) -> b
8    ));
9
10// groupingBy: all values preserved
11Map<String, List<String>> byDept = people.stream()
12    .filter(p -> p.email() != null)
13    .collect(Collectors.groupingBy(
14        Person::department,
15        Collectors.mapping(Person::email, Collectors.toList())
16    ));

groupingBy never throws on duplicate keys because it expects multiple values per key. It also handles null values in the grouped list without issues (the list can contain nulls).

Comparison of Approaches

ApproachNull values in result?Duplicate key handlingReadability
filter + toMapNo (excluded)Throws by defaultHigh
Default substitution + toMapNo (replaced)Throws by defaultHigh
Manual collect with HashMapYesLast writer winsMedium
Custom toNullableMap utilityYesConfigurableHigh (after initial definition)
groupingByYes (in lists)Built-in (groups)High

Common Pitfalls

Assuming Collectors.toMap behaves like HashMap.put. The most common surprise. toMap uses Map.merge internally, which rejects null values. HashMap.put does not share this restriction.

Fixing null values but forgetting about duplicate keys. These are independent failure modes. Filtering nulls does not prevent IllegalStateException from duplicates, and a merge function does not prevent NullPointerException from null values.

Using a sentinel value that looks like real data. Substituting "[email protected]" as a default is dangerous if downstream code might send emails to that address. Use clearly synthetic values like empty strings or dedicated constants.

Using the manual collector without considering parallel stream safety. The HashMap::putAll combiner in the manual approach has "last writer wins" semantics. If two parallel partitions contain the same key, one value is silently lost. If that is unacceptable, add conflict detection in the combiner.

Ignoring that toUnmodifiableMap (Java 10+) also rejects null values. Switching from toMap to toUnmodifiableMap does not solve the problem. Both use merge internally and both throw on null values.

Over-engineering with Optional. Wrapping every value in Optional<String> to avoid null creates a map that is verbose to consume. Prefer filtering or defaults over Map<K, Optional<V>> unless you have a specific reason to distinguish "present but empty" from "absent."

Summary

  • Collectors.toMap throws NullPointerException on null values because it uses Map.merge internally, not Map.put.
  • Filter null values before collecting when missing entries are acceptable.
  • Substitute a default value (using Objects.requireNonNullElse or a ternary) when every key must appear in the result.
  • Use the three-argument collect method with HashMap::new when the map genuinely needs to store null values.
  • Handle duplicate keys separately with a merge function in the three-argument toMap overload.
  • Consider groupingBy instead of toMap when multiple elements naturally share the same key.

Related reading
Course
Intermediate
27 lessons
14 hours
OOD Fundamentals

Master object-oriented design from first principles, SOLID, design patterns, and classic interview problems with hands-on coding.

View the course
Track what you have practised

A free account saves your progress, solutions and study plan across every problem on Codemia.

Interview Questions practice on Codemia

Over 8,000 real interview questions from top companies, searchable by company and role.

Browse interview questions

All Rights Reserved.