Java Stream collect() Method
Java Stream collect() Method
collect() is the terminal operation that gathers every element of a stream into a single mutable result — a List, a Set, a Map, a joined String, or a custom container — using a Collector that knows how to create that result, add elements into it, and, for a parallel stream, merge partial results back together. Where reduce() is built around producing a fresh, immutable value at every combining step, collect() is built around mutating one shared container as elements flow through it, which is exactly what makes it the right tool for accumulating into structures like List and Map efficiently.
What Is collect()?
collect() has two overloaded forms. The low-level one, <R> R collect(Supplier<R> supplier, BiConsumer<R, ? super T> accumulator, BiConsumer<R, R> combiner), takes three separate pieces directly: a Supplier that creates a new empty container, a BiConsumer that adds one element into it, and a second BiConsumer that merges two containers together when the stream runs in parallel. The high-level form, <R, A> R collect(Collector<? super T, A, R> collector), takes a single pre-built Collector object that already packages up all three of those pieces — this is the form almost every real codebase actually uses, through the java.util.stream.Collectors factory class and methods like toList(), toSet(), toMap(), and joining().
Why collect() Was Introduced
Building a new collection out of a stream's elements used to mean a loop, a manually created container, and an explicit add() call for every element.
1// File: BeforeCollect.java
2import java.util.*;
3
4public class BeforeCollect {
5 record Product(String sku, String name) {}
6
7 public static void main(String[] args) {
8 List<Product> catalog = List.of(
9 new Product("SKU-1", "Mouse"),
10 new Product("SKU-2", "Keyboard"),
11 new Product("SKU-3", "Monitor")
12 );
13
14 List<String> names = new ArrayList<>();
15 for (Product product : catalog) {
16 names.add(product.name());
17 }
18
19 System.out.println(names);
20 }
21}Output:
[Mouse, Keyboard, Monitor]
collect() keeps the same result but removes the container creation and the explicit add() call entirely — Collectors.toList() already knows how to do both.
1// File: AfterCollect.java
2import java.util.*;
3import java.util.stream.*;
4
5public class AfterCollect {
6 record Product(String sku, String name) {}
7
8 public static void main(String[] args) {
9 List<Product> catalog = List.of(
10 new Product("SKU-1", "Mouse"),
11 new Product("SKU-2", "Keyboard"),
12 new Product("SKU-3", "Monitor")
13 );
14
15 List<String> names = catalog.stream()
16 .map(Product::name)
17 .collect(Collectors.toList());
18
19 System.out.println(names);
20 }
21}Output:
[Mouse, Keyboard, Monitor]
Both versions build the same three-element list. The stream version has no ArrayList variable being created and populated by hand.
Syntax
The three-argument form is rarely written directly in real code — almost everything reaches for a Collector from the Collectors factory class instead.
1// File: CollectSyntaxForms.java
2import java.util.*;
3import java.util.stream.*;
4
5public class CollectSyntaxForms {
6 public static void main(String[] args) {
7 List<String> names = List.of("Mouse", "Keyboard", "Mouse", "Monitor");
8
9 // Low-level three-argument form - rarely written by hand in real code
10 ArrayList<String> viaThreeArg = names.stream()
11 .collect(ArrayList::new, ArrayList::add, ArrayList::addAll);
12
13 // The common, high-level form using a pre-built Collector
14 List<String> viaCollectorsToList = names.stream()
15 .collect(Collectors.toList());
16
17 Set<String> viaCollectorsToSet = names.stream()
18 .collect(Collectors.toSet());
19
20 String viaJoining = names.stream()
21 .collect(Collectors.joining(", "));
22
23 System.out.println("Three-arg form: " + viaThreeArg);
24 System.out.println("toList(): " + viaCollectorsToList);
25 System.out.println("toSet() size: " + viaCollectorsToSet.size());
26 System.out.println("joining(): " + viaJoining);
27 }
28}Output:
Three-arg form: [Mouse, Keyboard, Mouse, Monitor]
toList(): [Mouse, Keyboard, Mouse, Monitor]
toSet() size: 3
joining(): Mouse, Keyboard, Mouse, Monitor
Common Use Cases
Choosing Between toList, toSet, and toUnmodifiableList
toSet() removes duplicates as a side effect of the container it builds, and toUnmodifiableList() states its immutability guarantee directly in its name rather than leaving it implicit.
1// File: CollectorVarietyExample.java
2import java.util.*;
3import java.util.stream.*;
4
5public class CollectorVarietyExample {
6 public static void main(String[] args) {
7 List<String> categories = List.of("Electronics", "Books", "Electronics", "Home");
8
9 List<String> asList = categories.stream().collect(Collectors.toList());
10 List<String> asUnmodifiableList = categories.stream().collect(Collectors.toUnmodifiableList());
11 long distinctCount = categories.stream().collect(Collectors.toSet()).size();
12
13 System.out.println("As list: " + asList);
14 System.out.println("Distinct count: " + distinctCount);
15
16 try {
17 asUnmodifiableList.add("Toys");
18 } catch (UnsupportedOperationException e) {
19 System.out.println("toUnmodifiableList() rejects add() as expected");
20 }
21 }
22}Output:
As list: [Electronics, Books, Electronics, Home]
Distinct count: 3
toUnmodifiableList() rejects add() as expected
Joining Elements Into a Formatted String
Collectors.joining() accepts a delimiter along with an optional prefix and suffix, which is exactly what a CSV-style export or a formatted display string needs.
1// File: JoiningWithDelimitersExample.java
2import java.util.*;
3import java.util.stream.*;
4
5public class JoiningWithDelimitersExample {
6 public static void main(String[] args) {
7 List<String> skus = List.of("SKU-1", "SKU-2", "SKU-3");
8
9 String csvRow = skus.stream()
10 .collect(Collectors.joining(",", "[", "]"));
11
12 System.out.println(csvRow);
13 }
14}Output:
[SKU-1,SKU-2,SKU-3]
Building a Lookup Map
Collectors.toMap() takes a key extractor and a value extractor, turning a List into a Map for fast lookups instead of repeated linear scans.
1// File: ToMapLookupExample.java
2import java.util.*;
3import java.util.stream.*;
4
5public class ToMapLookupExample {
6 record Product(String sku, String name) {}
7
8 public static void main(String[] args) {
9 List<Product> catalog = List.of(
10 new Product("SKU-1", "Mouse"),
11 new Product("SKU-2", "Keyboard"),
12 new Product("SKU-3", "Monitor")
13 );
14
15 Map<String, Product> bySku = catalog.stream()
16 .collect(Collectors.toMap(Product::sku, product -> product));
17
18 System.out.println(bySku.get("SKU-2").name());
19 }
20}Output:
Keyboard
Summarizing With a Built-In Numeric Collector
Collectors.averagingDouble() and its relatives compute a single numeric summary directly, without a separate mapToDouble() step.
1// File: SummarizingCollectorExample.java
2import java.util.*;
3import java.util.stream.*;
4
5public class SummarizingCollectorExample {
6 record Order(String id, double amount) {}
7
8 public static void main(String[] args) {
9 List<Order> orders = List.of(
10 new Order("ORD-1", 450.0),
11 new Order("ORD-2", 899.0),
12 new Order("ORD-3", 250.0)
13 );
14
15 double averageOrderValue = orders.stream()
16 .collect(Collectors.averagingDouble(Order::amount));
17
18 System.out.println("Average order value: " + averageOrderValue);
19 }
20}Output:
Average order value: 533.0
Real-World Example
A warehouse system frequently needs to find a product by scanning its SKU barcode, and searching through the full catalog with filter and findFirst every single time a scan happens wastes work the moment the catalog grows past a handful of items. Building a Map<String, Product> once, up front, with collect(Collectors.toMap(...)), turns every later lookup into a direct, constant-time read instead of a fresh scan through the entire list.
1// File: Product.java
2
3public record Product(String sku, String name, double price) {}1// File: ProductCatalogIndex.java
2import java.util.*;
3import java.util.stream.*;
4
5public class ProductCatalogIndex {
6 private final Map<String, Product> bySku;
7
8 public ProductCatalogIndex(List<Product> catalog) {
9 this.bySku = catalog.stream()
10 .collect(Collectors.toMap(Product::sku, product -> product));
11 }
12
13 public Optional<Product> findBySku(String sku) {
14 return Optional.ofNullable(bySku.get(sku));
15 }
16
17 public String listAllSkus() {
18 return bySku.keySet().stream()
19 .sorted()
20 .collect(Collectors.joining(", "));
21 }
22
23 public int size() {
24 return bySku.size();
25 }
26}1// File: ProductCatalogIndexDemo.java
2import java.util.*;
3
4public class ProductCatalogIndexDemo {
5 public static void main(String[] args) {
6 List<Product> catalog = List.of(
7 new Product("SKU-1", "Wireless Mouse", 799.0),
8 new Product("SKU-2", "Mechanical Keyboard", 2499.0),
9 new Product("SKU-3", "27-inch Monitor", 8999.0)
10 );
11
12 ProductCatalogIndex index = new ProductCatalogIndex(catalog);
13
14 System.out.println("Catalog size: " + index.size());
15 System.out.println("All SKUs: " + index.listAllSkus());
16
17 index.findBySku("SKU-2").ifPresentOrElse(
18 product -> System.out.println("Found: " + product.name() + " at Rs." + product.price()),
19 () -> System.out.println("Not found")
20 );
21
22 index.findBySku("SKU-9").ifPresentOrElse(
23 product -> System.out.println("Found: " + product.name()),
24 () -> System.out.println("SKU-9 not found in catalog")
25 );
26 }
27}Output:
Catalog size: 3
All SKUs: SKU-1, SKU-2, SKU-3
Found: Mechanical Keyboard at Rs.2499.0
SKU-9 not found in catalog
A mistake that appears often in fresher pull requests is calling catalog.stream().filter(p -> p.sku().equals(sku)).findFirst() every single time a lookup is needed, scanning the entire catalog from scratch on every scan. Building the Map once with collect(Collectors.toMap(...)) when ProductCatalogIndex is constructed turns every later findBySku call into a constant-time lookup instead of a linear scan repeated over and over.
Combining collect() With Other Features
collect() is almost always where a filter() and map() chain ends, turning a narrowed and transformed stream into a concrete List, Set, or Map. The Collectors factory class provides most of what real code needs — toList, toSet, toMap, joining, counting, averagingDouble, and the grouping and partitioning collectors covered in their own dedicated article. collect() and reduce() solve overlapping problems, but collect() is specifically optimized for accumulating into a mutable container, while reduce() is built around producing a fresh, immutable result at every combining step — a distinction the JDK's own documentation calls out directly.
Best Practices
Reach for a Collectors factory method instead of the low-level three-argument form. Almost no real code needs to hand-write its own supplier, accumulator, and combiner when Collectors.toList(), toMap(), and joining() already cover the vast majority of cases.
When using toMap(), think through what should happen if two elements produce the same key before writing the call. The two-argument form throws IllegalStateException on a duplicate key, and a three-argument merge function is required the moment duplicate keys are actually possible in the data.
Prefer toUnmodifiableList() or toUnmodifiableSet() over toList() or toSet() whenever the collected result should never be mutated by whatever receives it, since that guarantee then lives directly in the type rather than being an assumption a caller has to trust.
Build a lookup structure like a Map once, up front, with collect(Collectors.toMap(...)), rather than repeatedly filtering or searching the same source for every individual lookup.
Common Mistakes
Using Collectors.toMap() without considering duplicate keys throws IllegalStateException the moment two elements produce the same key, and the fix is a three-argument merge function that decides explicitly what should happen.
1// File: ToMapDuplicateKeyMistake.java
2import java.util.*;
3import java.util.stream.*;
4
5public class ToMapDuplicateKeyMistake {
6 record Product(String sku, String name) {}
7
8 public static void main(String[] args) {
9 List<Product> catalog = List.of(
10 new Product("SKU-1", "Mouse"),
11 new Product("SKU-1", "Mouse (Refurbished)")
12 );
13
14 try {
15 Map<String, Product> broken = catalog.stream()
16 .collect(Collectors.toMap(Product::sku, product -> product));
17 System.out.println("Never printed: " + broken);
18 } catch (IllegalStateException e) {
19 System.out.println("IllegalStateException - duplicate key SKU-1 with no merge function supplied");
20 }
21
22 // Supplying a merge function resolves the conflict explicitly
23 Map<String, Product> resolved = catalog.stream()
24 .collect(Collectors.toMap(Product::sku, product -> product, (existing, replacement) -> existing));
25
26 System.out.println("Resolved: " + resolved.get("SKU-1").name());
27 }
28}Output:
IllegalStateException - duplicate key SKU-1 with no merge function supplied
Resolved: Mouse
Assuming Collectors.toList() guarantees a specific, mutable List implementation is relying on behavior the API never actually promises. The JDK documentation is explicit that no guarantee exists about the returned list's type, mutability, or thread-safety, even though current versions happen to return a plain mutable ArrayList.
1// File: ToListMutabilityAssumptionMistake.java
2import java.util.*;
3import java.util.stream.*;
4
5public class ToListMutabilityAssumptionMistake {
6 public static void main(String[] args) {
7 List<String> names = Stream.of("Ananya", "Rohit")
8 .collect(Collectors.toList());
9
10 // This happens to work on current JDK versions, but Collectors.toList()
11 // makes no documented guarantee about mutability, type, or thread-safety -
12 // code relying on it staying mutable is relying on unspecified behavior
13 names.add("Priya");
14 System.out.println(names);
15
16 // Collectors.toUnmodifiableList() states its contract explicitly instead
17 List<String> guaranteedImmutable = Stream.of("Ananya", "Rohit")
18 .collect(Collectors.toUnmodifiableList());
19
20 try {
21 guaranteedImmutable.add("Priya");
22 } catch (UnsupportedOperationException e) {
23 System.out.println("toUnmodifiableList() explicitly guarantees this fails");
24 }
25 }
26}Output:
[Ananya, Rohit, Priya]
toUnmodifiableList() explicitly guarantees this fails
Expecting Collectors.groupingBy() to preserve any particular order in its resulting Map is another common assumption that quietly breaks. Its default implementation returns a plain HashMap, which offers no ordering guarantee at all — code that needs a predictable order back from a grouped result has to request it explicitly, typically by supplying a TreeMap::new or LinkedHashMap::new supplier to the grouping collector.
Interview Questions
Q1. What does collect() do, and what are its two overloaded forms?
collect() is a terminal operation that gathers a stream's elements into a mutable result. Its low-level form takes a Supplier, a BiConsumer accumulator, and a BiConsumer combiner directly. Its high-level form takes a single Collector object that already packages all three, and this is the form used in the vast majority of real code through the Collectors factory class. Interviewers frequently ask for both forms specifically to see whether a candidate has ever looked past Collectors.toList() into what collect() is actually built on.
Q2. What is a Collector, and how does it relate to the three-argument collect() overload?
A Collector<T, A, R> is an object that bundles together the supplier, accumulator, and combiner the low-level collect() overload takes as three separate arguments, plus an optional finisher step for a final transformation. Collectors.toList(), Collectors.toMap(), and every other factory method on Collectors simply construct and return a pre-built Collector matching that shape, which is what makes collect(Collectors.toList()) equivalent to writing all three pieces out by hand.
Q3. What is the difference between collect() and reduce()?
reduce() is designed around producing a new, independent result at each combining step, which works well for immutable values like numbers and strings but performs poorly for building a mutable container, since every step would need to copy the entire container so far. collect() is designed specifically for mutable accumulation — the same container is created once and then mutated in place as elements flow through, which is far more efficient for building a List, Set, or Map.
Q4. What happens if Collectors.toMap() encounters two elements that produce the same key?
The two-argument form of toMap() throws IllegalStateException the moment a duplicate key is encountered, since it has no way to decide which of the two conflicting values should win. The three-argument form accepts an explicit merge function — a BinaryOperator<V> — that resolves the conflict deliberately, whether that means keeping the first value, the last one, or combining them somehow.
Q5. Does Collectors.toList() guarantee the returned list is mutable?
No, and this is a common misconception. The documented contract of Collectors.toList() makes no guarantee about the returned list's type, mutability, or thread-safety — current JDK versions happen to return a mutable ArrayList, but code that depends on that specifically should use Collectors.toCollection(ArrayList::new) instead, which states the intended container type explicitly rather than relying on unspecified behavior.
Q6. How does Collectors.joining() differ from manually concatenating strings in a loop?
Collectors.joining() handles delimiters, an optional prefix, and an optional suffix correctly without any special-casing for the first or last element, and internally it uses a StringBuilder-based accumulation rather than repeated string concatenation, which avoids the quadratic performance cost of concatenating strings with + inside a loop. Manually joining strings in a loop usually needs extra logic just to avoid a trailing delimiter after the last element, logic joining() already handles.
FAQs
Is collect() a terminal or intermediate operation?
Terminal. collect() consumes the entire stream and produces a final result — a List, Map, String, or whatever the supplied Collector builds — and nothing can be chained after it.
What is the difference between Collectors.toList() and Collectors.toUnmodifiableList()?
toList() makes no documented guarantee about the mutability of the list it returns, while toUnmodifiableList() explicitly guarantees an immutable list that throws UnsupportedOperationException on any attempt to modify it. Use toUnmodifiableList() whenever the collected result genuinely should never change after it is built.
Can collect() be used to build a custom object instead of a List or Map?
Yes. Collectors.toCollection() accepts any collection type as its target, and a fully custom Collector can be written to accumulate into any object at all, using Collector.of() to define its own supplier, accumulator, combiner, and finisher.
Does collect() work differently on a parallel stream?
The final result is the same as long as the Collector is properly associative and thread-safe in how it combines partial results, but the mechanics differ — a parallel stream builds several partial containers independently across threads and then merges them using the collector's combiner, rather than accumulating into a single container from start to finish.
What is the difference between Collectors.toMap() and Collectors.groupingBy()?
toMap() produces exactly one value per key, throwing or requiring a merge function on any duplicate. groupingBy() produces a Map where each key maps to a collection of every matching element, making it the correct choice whenever multiple elements can legitimately share the same key — it is covered in depth in its own dedicated article.
Can I collect a stream directly into an array instead of a List?
Not through collect() — arrays use a separate method, toArray(), which is not part of the Collector framework at all. collect(Collectors.toList()) followed by .toArray() is one way to get there, but stream.toArray(String[]::new) is the more direct route for a typed array.
Is Collectors.joining() the same as String.join()?
They produce similar results but operate on different inputs. String.join() takes a delimiter and a CharSequence... elements varargs array or an Iterable<CharSequence> directly. Collectors.joining() is a Collector meant specifically for use inside a stream's collect() call, working on whatever String elements the stream happens to be producing at that point in the pipeline.
Summary
collect() is how a stream turns into something concrete — a List, a Set, a Map, a formatted String — and nearly all of that work happens through a pre-built Collector from the Collectors factory class rather than the low-level three-argument form. toList(), toSet(), toMap(), and joining() cover most everyday needs, and the product catalog index example above shows exactly why toMap() earns its place: turning a repeated linear search into a single upfront build followed by constant-time lookups.
The two habits worth carrying forward are thinking through duplicate keys before calling toMap(), and reaching for toUnmodifiableList() the moment a collected result is meant to stay fixed. reduce() remains the right tool for building an immutable value one combining step at a time — collect() takes over the moment the goal shifts to building something mutable that gets filled in as the stream runs.
What to Read Next
Learn how to sort the elements in a Stream.