Ch. 7 · Java

Java 'stream has already been operated upon or closed' Explained

Why reusing a Java stream throws IllegalStateException, how to share data with a Supplier or a collection, and what toList returns.

~6 min readintermediateupdated Oct 6, 2026

Interviewers often show this snippet and ask what happens:

Stream<String> names = users.stream().map(User::name);
long count = names.count();
List<String> list = names.toList();
java

The second terminal operation throws IllegalStateException: stream has already been operated upon or closed. The question checks whether you understand that a stream is a one-shot pipeline rather than a collection, and the follow-ups explore API design: should a method return a Stream or a List, and who owns the returned data?

Before you start

You should know how to build simple pipelines with filter, map and collect, and the difference between intermediate and terminal operations. Reading Java streams and lazy pipelines first helps, since reuse rules follow directly from laziness.

The short answer

A stream can be traversed only once. The first terminal operation consumes it and marks every stage of the pipeline as linked or consumed, so any further intermediate or terminal call throws IllegalStateException. If you need the data several times, collect it into a collection and stream that collection again, or keep a Supplier<Stream<T>> that builds a fresh pipeline per use. Return collections from APIs by default, and remember that Stream.toList() produces an unmodifiable list.

How it works

Every pipeline stage in OpenJDK extends AbstractPipeline, which has a linkedOrConsumed flag. Two actions set it: attaching a new stage (calling map on a stream) and starting a terminal operation. Both check the flag first:

Stream<String> base = Stream.of("a", "b", "c");
Stream<String> upper = base.map(String::toUpperCase);   // base is now linked
Stream<String> lower = base.map(String::toLowerCase);   // IllegalStateException
java

So even branching one stream into two pipelines is illegal, not only running two terminal operations.

The one-shot design is deliberate. Streams can wrap sources that cannot be replayed: lines from a network socket, Files.lines reading a file, a generator, or an iterator. Allowing a second traversal would mean either buffering everything, which defeats laziness, or silently producing different results. The API refuses instead.

Collections do not have this restriction. Every call to list.stream() creates a new pipeline over the same data:

List<String> names = users.stream().map(User::name).toList();
long count = names.size();
List<String> sorted = names.stream().sorted().toList();   // fine: a new stream
java

Step-by-step walkthrough

Step 1: Recognise the reuse

Look for a stream stored in a variable or field and used in two places. The bug often hides when one use is in a helper method:

Stream<Order> open = orders.stream().filter(Order::isOpen);
log.info("open orders: {}", open.count());
return summarise(open);                   // throws inside summarise
java

Step 2: Materialise when data is reused

List<Order> open = orders.stream().filter(Order::isOpen).toList();
log.info("open orders: {}", open.size());
return summarise(open);
java

Collecting once is usually cheaper than recomputing the filter, and it gives summarise a stable snapshot.

Step 3: Use a Supplier when recomputation is intended

If the source is cheap to re-read and you want the pipeline description reused, wrap it:

Supplier<Stream<Order>> open = () -> orders.stream().filter(Order::isOpen);
long count = open.get().count();
Map<String, List<Order>> byRegion = open.get().collect(groupingBy(Order::region));
java

Each get() creates a new pipeline. This only works when the underlying source can be traversed again; a supplier wrapping an Iterator would give an empty second stream.

Step 4: Choose the right collector for ownership

List<String> a = stream.toList();                         // Java 16+: unmodifiable, allows nulls
List<String> b = stream.collect(Collectors.toList());     // currently an ArrayList, but unspecified
List<String> c = stream.collect(Collectors.toUnmodifiableList()); // unmodifiable, rejects nulls
List<String> d = stream.collect(Collectors.toCollection(ArrayList::new)); // guaranteed mutable
java

(Each line needs its own fresh stream, of course.) Pick based on whether the caller is allowed to modify the result. If callers must add elements, use toCollection(ArrayList::new) to make that a documented guarantee instead of relying on Collectors.toList() implementation details.

Worked scenario

A reporting service exposes a method used by several controllers:

public Stream<Transaction> transactionsFor(String accountId) {
    return Files.lines(path(accountId)).map(Transaction::parse);
}
java

One controller calls it, counts the results for a header, then streams them again into the response, which throws IllegalStateException. A developer “fixes” that by calling transactionsFor twice. Now each request opens the file twice, and because nobody closes the streams returned by Files.lines, file handles leak until the process hits its descriptor limit.

The root issue is ownership: returning a Stream hands the caller a one-shot, resource-holding object with no clear rule about who closes it. The redesigned API returns a collection for normal use and provides a callback-style method for very large files:

public List<Transaction> transactionsFor(String accountId) throws IOException {
    try (Stream<String> lines = Files.lines(path(accountId))) {
        return lines.map(Transaction::parse).toList();
    }
}

public <R> R withTransactions(String accountId, Function<Stream<Transaction>, R> work) throws IOException {
    try (Stream<String> lines = Files.lines(path(accountId))) {
        return work.apply(lines.map(Transaction::parse));
    }
}
java

The service owns the file handle and always closes it. Callers that need the data twice get a list; callers streaming a huge file pass a function that consumes the stream exactly once inside the managed scope.

Common mistake

  • Storing streams in fields. A field of type Stream can be consumed once, ever. Store the collection.
  • Calling count before reusing. count() is terminal. Use the collection’s size() instead.
  • Branching one stream into two pipelines. Calling map twice on the same stream object throws.
  • Mutating the source during the pipeline. Adding to an ArrayList from inside its own stream’s forEach throws ConcurrentModificationException or gives undefined results; the stream documentation requires non-interference.
  • Assuming Collectors.toList is mutable forever. Its type is unspecified; rely on toCollection if you need mutability.

Verify the behavior

import static org.junit.jupiter.api.Assertions.*;
import java.util.*;
import java.util.function.Supplier;
import java.util.stream.*;
import org.junit.jupiter.api.Test;

class ReuseTest {
    @Test
    void secondTerminalOperationThrows() {
        Stream<String> s = Stream.of("a", "b");
        s.count();
        assertThrows(IllegalStateException.class, s::toList);
    }

    @Test
    void branchingThrows() {
        Stream<String> s = Stream.of("a");
        s.map(String::toUpperCase);
        assertThrows(IllegalStateException.class, () -> s.map(String::toLowerCase));
    }

    @Test
    void supplierCreatesFreshPipelines() {
        List<Integer> data = List.of(1, 2, 3);
        Supplier<Stream<Integer>> evens = () -> data.stream().filter(i -> i % 2 == 0);
        assertEquals(1, evens.get().count());
        assertEquals(List.of(2), evens.get().toList());
    }

    @Test
    void toListIsUnmodifiable() {
        List<String> list = Stream.of("a").toList();
        assertThrows(UnsupportedOperationException.class, () -> list.add("b"));
    }
}
java

Follow-up questions

Should a public method return Stream or List? Return a List or Collection by default; it is reusable, has a size and is easy to debug. Return a Stream when results are huge or lazily produced, and document who closes it if it holds resources.

Do I need to close streams? Only those backed by I/O, such as Files.lines, Files.list and Files.walk. Collection-backed streams hold no resources, though closing them is harmless.

What is the difference between toList() and Collectors.toUnmodifiableList()? Both are unmodifiable. Stream.toList() permits null elements; toUnmodifiableList() throws NullPointerException on nulls.

Can an Iterable be streamed multiple times? Yes, through StreamSupport.stream(iterable.spliterator(), false) per use, as long as the Iterable returns a fresh iterator each time.

Interview exercise

A colleague writes a utility that caches expensive query results as private final Stream<Product> catalog = loadCatalog(); and two methods read from it. What happens, and what would you store instead?

Answer and reasoning

The first method that calls a terminal operation (or even an intermediate one) on catalog consumes or links the stream; every later call from either method throws IllegalStateException. The intent was caching, but a stream caches nothing: it is a recipe bound to a one-shot source, and if loadCatalog was lazy, the expensive query might not even have run yet.

Store the materialised result, private final List<Product> catalog = List.copyOf(loadCatalog().toList()); (or simply loadCatalog().toList(), which is already unmodifiable), and have each method call catalog.stream() for a fresh pipeline. If the data must refresh, store a Supplier<List<Product>> backed by a cache with expiry. The reasoning interviewers want: streams describe computations, collections hold data, and caching requires holding data.

Continue learning

Practise more with the Java interview questions and the Java MCQs. Related notes: streams and lazy pipelines, try-with-resources and cleanup and ConcurrentModificationException. See the non-interference and reuse rules in the java.util.stream package documentation.

More in Java

esc