Definition
Collectors.collectingAndThen composes two collectors into one.
- Downstream collector – the collector that gathers the stream, such as
toList()ortoMap(...). - Finisher function – a
Functionapplied once to the downstream result before it is returned. - Single pipeline – the finishing step runs inside the collect call, so you do not need a separate line to transform the result.
- Common use – wrapping the mutable result with
Collections::unmodifiableListorCollections::unmodifiableMapfor immutability.
The finisher can perform any final transformation, but immutability is the most frequent reason to reach for it.
At National Bank of India, Rajesh prepares a list of account holders for an audit. He writes all the names down (the downstream collector) and then stamps the sheet "FINAL – DO NOT EDIT" before handing it over (the finisher). Once stamped, nobody can add or erase a name; any attempt is rejected. collectingAndThen is that two-step routine of gathering first and stamping second, and unmodifiableList is the stamp that makes edits bounce back.
Understanding the Concepts
What does collectingAndThen do?
collectingAndThen takes an existing collector and a finisher function. It runs the collector to produce a result, then passes that result through the finisher and returns whatever the finisher returns. It is a way to add one final transformation to a collecting pipeline.
package com.bank.hr;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
public class ImmutableNames {
public static List<String> names(List<Employee> employees) {
return employees.stream()
.map(Employee::getName)
.collect(Collectors.collectingAndThen(
Collectors.toList(), // downstream: gather into a list
Collections::unmodifiableList)); // finisher: make it read-only
}
}
How does it wrap a collector with a finishing step?
Internally a collector has a finisher stage. collectingAndThen replaces or extends that stage by composing your function after the downstream collector finishes. The flow is: supply container, accumulate elements, finish the downstream, then apply your finisher.
downstream.supplier() -> new ArrayList
downstream.accumulator() -> add each name
downstream.finisher() -> the ArrayList
your finisher -> Collections.unmodifiableList(list)
returned result -> read-only List<String>
How do you build an unmodifiable id-to-name map?
Use toMap as the downstream collector and Collections::unmodifiableMap as the finisher.
package com.bank.hr;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
public class ImmutableLookup {
public static Map<Long, String> idToName(List<Employee> employees) {
return employees.stream()
.collect(Collectors.collectingAndThen(
Collectors.toMap(Employee::getId, Employee::getName),
Collections::unmodifiableMap));
}
}
{101=Rajesh, 102=Priya, 103=Pankaj}
Any attempt to modify the returned map now fails.
Why is it used for immutability?
Streams often collect into a mutable ArrayList or HashMap. If you return that directly, any caller can change it and introduce bugs. collectingAndThen lets you return a read-only view in the same expression, so the mutable container never escapes.
package com.bank.hr;
import java.util.List;
public class ReadOnlyDemo {
public static void main(String[] args) {
List<Employee> employees = List.of(
new Employee(101L, "Rajesh"),
new Employee(102L, "Priya"));
List<String> names = ImmutableNames.names(employees);
names.add("Intruder"); // not allowed
}
}
Exception in thread "main" java.lang.UnsupportedOperationException
at java.base/java.util.Collections$UnmodifiableCollection.add(Collections.java:1067)
How is this different from collecting then wrapping on a separate line?
The result is similar, but collectingAndThen keeps everything in one pipeline and never exposes the mutable container to the surrounding code. The two-line version creates a named mutable variable that could be mutated before wrapping.
| Approach | Mutable container exposed? | Lines | Risk |
|----------|----------------------------|-------|------|
| Collect then wrap separately | yes, as a local variable | two statements | can be mutated before wrapping |
| collectingAndThen | no, wrapped inline | one expression | mutable container never escapes |
// Two-line version: the mutable list exists as a variable first
List<String> tmp = employees.stream().map(Employee::getName)
.collect(Collectors.toList());
List<String> readOnly = Collections.unmodifiableList(tmp);
// One-expression version: no intermediate variable to misuse
List<String> readOnly2 = employees.stream().map(Employee::getName)
.collect(Collectors.collectingAndThen(
Collectors.toList(), Collections::unmodifiableList));
How is unmodifiableList different from List.copyOf and toUnmodifiableList?
unmodifiableList returns a read-only view over the original list, so changes to the original still show through. List.copyOf and Collectors.toUnmodifiableList() create a truly independent immutable copy.
| Technique | Independent copy? | Reflects backing changes? | Allows nulls? |
|-----------|-------------------|---------------------------|---------------|
| Collections.unmodifiableList(list) | no, a view | yes | yes (if backing allows) |
| List.copyOf(list) | yes | no | no, rejects nulls |
| Collectors.toUnmodifiableList() | yes | n/a | no, rejects nulls |
// Truly immutable, no backing list to leak (Java 10+)
List<String> names = employees.stream()
.map(Employee::getName)
.collect(Collectors.toUnmodifiableList());
What are the common mistakes?
The first mistake is keeping a reference to the backing collection, so a read-only view still changes underneath.
// WRONG: the view reflects later mutations of the backing list
List<String> backing = new java.util.ArrayList<>(List.of("Rajesh"));
List<String> view = Collections.unmodifiableList(backing);
backing.add("Priya"); // mutates the backing list
System.out.println(view); // [Rajesh, Priya] - surprise!
// CORRECT: use collectingAndThen so no backing reference escapes,
// or use toUnmodifiableList for a true copy
List<String> view = employees.stream().map(Employee::getName)
.collect(Collectors.toUnmodifiableList());
The second mistake is expecting the view to be modifiable and calling add or put, which throws UnsupportedOperationException.
What are the edge cases with nulls?
collectingAndThen with toUnmodifiableList or List.copyOf rejects null elements. toMap already rejects null values. If your data can contain nulls and you want an unmodifiable result, filter them first or use unmodifiableList over a list that permits nulls.
// Rejects null, throws NullPointerException if a name is null
List<String> names = employees.stream()
.map(Employee::getName)
.collect(Collectors.toUnmodifiableList());
// Filter nulls before collecting if they can occur
List<String> safe = employees.stream()
.map(Employee::getName)
.filter(java.util.Objects::nonNull)
.collect(Collectors.toUnmodifiableList());
When should you use collectingAndThen?
Use it whenever a collecting pipeline needs one final transformation: wrapping for immutability, converting a collected list into an array, or converting a count into a report object. Reach for toUnmodifiableList/toUnmodifiableMap directly when you only need an immutable copy and nothing else.
// Finisher that is not about immutability: list -> count summary
String summary = employees.stream()
.collect(Collectors.collectingAndThen(
Collectors.counting(),
count -> "Total employees: " + count));
What should a senior engineer know?
- Views versus copies.
Collections.unmodifiable*returns a live view;List.copyOfandtoUnmodifiableListreturn an independent copy. Prefer copies when the backing data may change. - Null policy. The Java 9+ immutable factories and collectors reject null; the older
unmodifiable*wrappers do not. - Composability.
collectingAndThencomposes with any downstream collector, includinggroupingBy, letting you make the grouped lists immutable:groupingBy(dept, collectingAndThen(toList(), List::copyOf)). - Performance. The finisher runs once at the end, so it adds negligible cost compared to the collection itself.
What are common interview follow-up questions?
- "What are the two arguments to
collectingAndThen?" Answer: a downstream collector and a finisher function. - "Why use it instead of wrapping on a separate line?" Answer: it keeps the mutable container from escaping and keeps the pipeline to one expression.
- "Difference between
unmodifiableListandtoUnmodifiableList?" Answer: the former is a view over the original; the latter is an independent immutable copy that rejects nulls. - "What exception do you get modifying the result?" Answer:
UnsupportedOperationException.
Visual Overview
Stream<Employee>
│ map(Employee::getName)
▼
Stream<String>
│
│ collect(collectingAndThen(DOWNSTREAM, FINISHER))
▼
┌──────────────────────────────────────────────┐
│ collectingAndThen │
│ │
│ DOWNSTREAM = toList() │
│ supplier -> new ArrayList │
│ accumulate -> add each name │
│ finisher -> ArrayList [Rajesh, Priya] │
│ │ │
│ FINISHER = unmodifiableList │
│ ▼ │
│ read-only List<String> │
└──────────────────────────────────────────────┘
│
▼
add()/remove() ──▶ UnsupportedOperationException
View vs Copy ┌──────────────────────────────┐ ┌──────────────────────────────┐ │ unmodifiableList (a VIEW) │ │ toUnmodifiableList (a COPY) │ │ backing ──┬── view │ │ source --> independent list │ │ change backing -> view changes│ │ change source -> copy intact │ └──────────────────────────────┘ └──────────────────────────────┘
Key Takeaway
When Rajesh collects account-holder names for an audit, he does two things: he writes down every name, and then he stamps the sheet "FINAL – DO NOT EDIT" before handing it over. collectingAndThen is exactly that two-step routine in code. The first argument is the downstream collector that gathers the data, such as toList() or toMap(...). The second argument is a finisher function that runs once on the collected result.
The most common finisher is Collections::unmodifiableList or Collections::unmodifiableMap, which wraps the mutable result as read-only. The value is that the mutable ArrayList or HashMap never escapes into the surrounding code. Everything happens inside one collect expression, so no caller gets a chance to mutate the container before it is sealed.
There is a subtlety worth remembering. Collections.unmodifiable* returns a view over the original collection, so if you still hold the backing reference and change it, the "read-only" view changes too. For a truly independent result, prefer List.copyOf or Collectors.toUnmodifiableList(), which make a real copy and reject null elements.
The finisher does not have to be about immutability. It can turn a count into a formatted report, convert a list into an array, or wrap grouped lists so each group is immutable. The pattern is always the same: collect, then finish.
In our banking work the habit is to never hand out a mutable collection you built in a stream. Seal it with collectingAndThen so accidental edits bounce back as UnsupportedOperationException.
Remember one sentence: collectingAndThen runs a collector and then applies a finishing function, most often wrapping the result as unmodifiable so it leaves the pipeline read-only.
---