Back to Blog
Java

Java Collectors.toMap: Usage and Edge Cases

Learn how to use Java's Collectors.toMap() to convert streams into maps, handle duplicate keys with merge functions, and avoid common pitfalls.

Java StreamsCollectorstoMapJava CollectionsFunctional ProgrammingMap
A diagram showing stream elements flowing into a Java Map container with duplicate key resolution.

The Java Collectors.toMap() method is the standard way to convert a stream into a Map instance. It looks simple at first glance, but its behavior changes significantly depending on which overload you use and what your data contains. This article covers the overloads, the merge function for duplicate keys, the map supplier, and edge cases that commonly break production code.

The Two-Argument Overload

The most common form takes two functions: one that extracts the key and one that extracts the value.

Map<Integer, String> idToName = users.stream() .collect(Collectors.toMap(User::getId, User::getName));

The key mapper and value mapper are applied to each element in the stream. The collector accumulates the results into a HashMap by default. This overload is sufficient when every key in the stream is unique.

If the stream contains two elements that produce the same key, this overload throws an IllegalStateException. The exception message includes the duplicate key, which helps during debugging but does not prevent the failure.

Handling Duplicate Keys with a Merge Function

The three-argument overload adds a mergeFunction that resolves conflicts when two elements map to the same key.

Map<String, Double> productPrices = products.stream() .collect(Collectors.toMap( Product::getSku, Product::getPrice, (first, second) -> second ));

The merge function receives the two values that collide and returns the value that should be stored. Using (first, second) -> second keeps the last occurrence. Using (first, second) -> first keeps the first. You can also combine values, for example summing them or concatenating strings.

Map<String, Integer> totalByCategory = orders.stream() .collect(Collectors.toMap( Order::getCategory, Order::getQuantity, Integer::sum ));

This pattern is common when aggregating values from a stream where the same key appears multiple times.

Choosing the Map Implementation with a Supplier

The four-argument overload accepts a Supplier that provides the map instance. This is useful when you need a specific map type, such as LinkedHashMap to preserve insertion order, or TreeMap for sorted keys.

Map<String, Integer> sortedCounts = words.stream() .collect(Collectors.toMap( word -> word, word -> 1, Integer::sum, TreeMap::new ));

Without the supplier, the collector always produces a HashMap, which makes no guarantees about iteration order. If your downstream code depends on order, either use a LinkedHashMap or sort the map afterward.

Null Values and Null Keys

Collectors.toMap() does not allow null values. If the value mapper returns null for any element, the collector throws a NullPointerException when it tries to merge the value into the map. This differs from HashMap, which accepts null values by itself, and it is a frequent source of runtime failures when data comes from external systems.

A null key is a different case: whether it is accepted depends on the map implementation used by the collector. The default result is a HashMap, which allows a single null key. A TreeMap supplier, Collectors.toConcurrentMap(), or any map implementation that forbids null keys will throw an exception instead.

If your data may contain nulls and you need predictable behavior, filter them before collecting:

Map<String, String> validEntries = records.stream() .filter(r -> r.getKey() != null && r.getValue() != null) .collect(Collectors.toMap(Record::getKey, Record::getValue));

Performance and Memory Considerations

The default HashMap gives constant-time average lookup and insertion, which is appropriate for most workloads. The merge function runs once for each additional element that maps to an existing key, so its cost is proportional to the number of collisions.

When you need a thread-safe map, use Collectors.toConcurrentMap() instead of passing a ConcurrentHashMap supplier to toMap(). The concurrent variant uses ConcurrentHashMap internally and supports parallel streams without external synchronization. The merge function must be associative and stateless for parallel execution to produce correct results.

Choosing Between toMap and groupingBy

Collectors.toMap() maps each element to exactly one key-value pair. Collectors.groupingBy() groups elements that share a key into a List. The two are not interchangeable.

// toMap: one value per key Map<String, Integer> countByCity = people.stream() .collect(Collectors.toMap(Person::getCity, p -> 1, Integer::sum)); // groupingBy: list of values per key Map<String, List<Person>> peopleByCity = people.stream() .collect(Collectors.groupingBy(Person::getCity));

Use toMap() when you need a single aggregated value per key. Use groupingBy() when you need to retain all elements that share a key. The distinction matters because toMap() throws on duplicate keys without a merge function, while groupingBy() collects duplicates into a list. Choosing the wrong collector can turn a straightforward transformation into an unexpected runtime failure.

Java Collectors.toMap: Usage, Overloads, and Edge Cases | RYUSLOG DEV