To collect a stream into an immutable list, we call stream.toList() (Java 16+) or stream.collect(Collectors.toUnmodifiableList()) (Java 10+), and both return a list that throws UnsupportedOperationException on any change. The difference is null handling, because toList() accepts null elements and toUnmodifiableList() rejects them with a NullPointerException.
We collect into unmodifiable collections whenever a method returns data that callers must not change, such as configuration values, lookup tables or a cached query result. The same family of collectors also builds unmodifiable sets and maps.
The following example collects the same city names in both ways and tries to change the result.
List<String> cities = Stream.of("Pune", "Oslo", "Lima").toList(); // [Pune, Oslo, Lima]
List<String> strict = Stream.of("Pune", "Oslo").collect(Collectors.toUnmodifiableList()); // [Pune, Oslo]
List<String> withNull = Stream.of("Pune", null).toList(); // [Pune, null]
cities.add("Rome"); // UnsupportedOperationException
The null element survives toList(), which is the main difference between the two calls. We cover each method in turn, compare them side by side with Collectors.toList() and collectingAndThen(), collect into unmodifiable sets and maps, and explain why an unmodifiable list is not always immutable.
1. Stream.toList() in Java 16
The terminal operation Stream.toList() was added in Java 16 as a shorter form of collect(Collectors.toList()) for the common case where nobody modifies the result. The returned list is unmodifiable, every mutator method throws UnsupportedOperationException, and the elements keep the stream’s encounter order.
List<String> tags = Stream.of("java", "spring", "java").distinct().toList(); // [java, spring]
String first = tags.getFirst(); // "java"
boolean hasNull = tags.contains(null); // false
tags.set(0, "kotlin"); // UnsupportedOperationException
The method toList() makes no promise about the implementation class or serializability of the list, so code must not depend on the class it gets back. It does allow null elements, which matters when the stream maps values that can be missing, such as an optional middle name.
2. Collectors.toUnmodifiableList(), toUnmodifiableSet() and toUnmodifiableMap()
Java 10 added three collectors that return unmodifiable collections. All three throw NullPointerException when they meet a null element, key or value, so they fail fast on bad data instead of storing it.
List<String> origins = Stream.of("a.com", "b.com").collect(Collectors.toUnmodifiableList()); // [a.com, b.com]
Set<String> roles = Stream.of("admin", "user", "admin").collect(Collectors.toUnmodifiableSet()); // admin and user, order not defined
int roleCount = roles.size(); // 2
List<String> bad = Stream.of("a.com", null).collect(Collectors.toUnmodifiableList()); // NullPointerException
The set collector drops duplicates, and its iteration order is not specified, so we never rely on the order of an unmodifiable set. When the order matters, we collect into a list or sort the elements first.
The map collector takes a key mapper and a value mapper. Two elements with the same key make it throw IllegalStateException, and a third argument, the merge function, decides which value to keep instead.
Map<String, Integer> lengths = Stream.of("kiwi", "fig").collect(Collectors.toUnmodifiableMap(s -> s, String::length)); // {kiwi=4, fig=3} in any order
Integer kiwi = lengths.get("kiwi"); // 4
Map<Integer, String> byLength = Stream.of("kiwi", "pear", "fig").collect(Collectors.toUnmodifiableMap(String::length, s -> s)); // IllegalStateException: Duplicate key 4
Map<Integer, String> merged = Stream.of("kiwi", "pear", "fig").collect(Collectors.toUnmodifiableMap(String::length, s -> s, (a, b) -> a + "," + b));
String four = merged.get(4); // "kiwi,pear"
The exception message names the key and both values, for example Duplicate key 4 (attempted merging values kiwi and pear), which helps when the stream comes from a database or a file. The post on immutable and unmodifiable maps covers the other ways to build a read-only map.
3. Comparing toList(), toUnmodifiableList() and Collectors.toList()
The three methods look interchangeable, and IDEs often suggest replacing one with another. They differ in mutability and in null handling, so a blind replacement can break working code.

| Method | Since | Result can be modified | null elements | Typical use |
|---|---|---|---|---|
| stream.toList() | Java 16 | No | Allowed | Default choice for a read-only result |
| collect(Collectors.toUnmodifiableList()) | Java 10 | No | NullPointerException | Read-only result that must not contain null |
| collect(Collectors.toList()) | Java 8 | Not guaranteed, an ArrayList in practice | Allowed | Avoid when we plan to modify, use toCollection() |
| collect(Collectors.toCollection(ArrayList::new)) | Java 8 | Yes, guaranteed | Allowed | A list we add to or sort afterwards |
| collect(collectingAndThen(toList(), Collections::unmodifiableList)) | Java 8 | No | Allowed | Java 8 code bases |
The collector Collectors.toList() gives no guarantee on the type or mutability of the list, so code that adds to the result should use toCollection(ArrayList::new). The current JDK returns an ArrayList, but that is an implementation detail.
List<String> editable = Stream.of("b", "a").collect(Collectors.toCollection(ArrayList::new));
editable.add("c");
editable.sort(null);
List<String> sorted = editable; // [a, b, c]
4. collectingAndThen() and Collections.unmodifiableList()
Before Java 10, the usual way to get an unmodifiable list from a stream was collectingAndThen(), which runs a finishing function on the collected result. The finisher Collections::unmodifiableList wraps the fresh ArrayList in a read-only view.
List<String> wrapped = Stream.of("Pune", "Oslo").collect(Collectors.collectingAndThen(Collectors.toList(), Collections::unmodifiableList)); // [Pune, Oslo]
List<String> keepsNull = Stream.of("Pune", null).collect(Collectors.collectingAndThen(Collectors.toList(), Collections::unmodifiableList)); // [Pune, null]
A view is not a copy. When we wrap a list that other code still holds, changes to that list show through the wrapper, whereas List.copyOf() makes an independent unmodifiable copy.
List<String> source = new ArrayList<>(List.of("Pune"));
List<String> view = Collections.unmodifiableList(source);
List<String> copy = List.copyOf(source);
source.add("Oslo");
List<String> viewNow = view; // [Pune, Oslo]
List<String> copyNow = copy; // [Pune]
Inside collectingAndThen(), no other code holds the inner ArrayList, so the view is safe there. In Java 10 and later, toUnmodifiableList() or toList() gives the same result with less code.
5. Returning Read-Only Data From a Config Class
A web app loads its allowed CORS origins at startup and hands the list to several filters. If one filter adds an origin to the shared list, every other filter starts accepting it, and the bug is hard to trace. Returning an unmodifiable list turns that mistake into an immediate UnsupportedOperationException.
record CorsSettings(List<String> allowedOrigins) {
CorsSettings {
allowedOrigins = List.copyOf(allowedOrigins);
}
static CorsSettings fromProperty(String value) {
return new CorsSettings(Arrays.stream(value.split(",")).map(String::strip).filter(s -> !s.isEmpty()).toList());
}
}
CorsSettings settings = CorsSettings.fromProperty(" https://a.com, https://b.com ,");
List<String> origins = settings.allowedOrigins(); // [https://a.com, https://b.com]
settings.allowedOrigins().add("https://evil.com"); // UnsupportedOperationException
The compact constructor calls List.copyOf(), so the record stays read-only even when a caller passes a mutable list. The strip() and filter() calls handle spaces and the trailing comma in the property value. The same idea applies to any immutable class.
6. Unmodifiable vs Immutable Collections
An unmodifiable collection rejects add(), remove() and set(). An immutable collection also guarantees that its content never changes, which holds only when nobody else can change the backing data and the elements themselves are immutable. Java calls the results of List.of(), toList() and the collectors unmodifiable, not immutable, because these methods cannot control the elements.
List<StringBuilder> drafts = Stream.of(new StringBuilder("draft")).toList();
drafts.getFirst().append(" v2");
String text = drafts.getFirst().toString(); // "draft v2"
We could not add or remove elements, but we changed the content of one element. For collections that are immutable in practice, we store immutable elements such as String, boxed numbers or records with immutable components. The post on immutable collection factory methods covers List.of(), Set.of() and Map.of().
7. Guava ImmutableList.toImmutableList()
A project that already depends on Guava 21 or later can collect into Guava’s own immutable types. The ImmutableList type rejects null like toUnmodifiableList(), and its type name tells the reader of a method signature that the list cannot change.
ImmutableList<String> guavaList = Stream.of("Pune", "Oslo").collect(ImmutableList.toImmutableList()); // [Pune, Oslo]
ImmutableSet<String> guavaSet = Stream.of("b", "a", "b").collect(ImmutableSet.toImmutableSet()); // [b, a]
Unlike the JDK set, ImmutableSet keeps the first-seen order of the elements. For new code without Guava, the JDK collectors cover the same needs.
8. Immutable Stream Collection FAQs
Migrations from Java 8 and IDE quick fixes raise questions about immutability, safe replacement, null lookups and mutable results.
8.1. Is the List From Stream.toList() Immutable?
The list is unmodifiable. Every mutator method throws UnsupportedOperationException, but mutable elements inside it can still change, as section 6 shows.
8.2. Can We Replace collect(Collectors.toList()) With toList() Everywhere?
No. The replacement is safe only when no code adds to, removes from or sorts the result. Code such as list.add(…) or Collections.sort(list) after the stream throws UnsupportedOperationException once we switch, so we check each call site. Sonar rule S6204 and IntelliJ IDEA suggest the replacement, and the check stays our job.
8.3. Does contains(null) Work on These Lists?
Only on the list from toList(), which returns false. The list from toUnmodifiableList(), like List.of(), throws NullPointerException for contains(null).
List<String> fromToList = Stream.of("a").toList();
boolean safe = fromToList.contains(null); // false
List<String> fromCollector = Stream.of("a").collect(Collectors.toUnmodifiableList());
boolean strict = fromCollector.contains(null); // NullPointerException
8.4. How Do We Collect a Stream Into a Regular Mutable List?
We use collect(Collectors.toCollection(ArrayList::new)), which guarantees an ArrayList. The post on collecting a stream into a List compares all the list collectors.
9. Conclusion
For a read-only result, stream.toList() is the shortest choice from Java 16 on, and it accepts null. The Collectors.toUnmodifiableList(), toUnmodifiableSet() and toUnmodifiableMap() collectors reject null, and the map collector also throws IllegalStateException on duplicate keys unless we give it a merge function.
When we need a list to change later, we collect with toCollection(ArrayList::new) instead of relying on Collectors.toList(). Because all of these collections are unmodifiable rather than deeply immutable, we fill them with immutable elements and use List.copyOf() when a class receives a list from outside.
10. References
- Stream.toList() (Java SE 25 API)
- Collectors (Java SE 25 API)
- Unmodifiable Lists (Java SE 25 API)
- Collections.unmodifiableList() (Java SE 25 API)
- Guava ImmutableList
Happy Learning !!