What is the Stream API, and how do intermediate and terminal operations differ?

9 minintermediatestream-apijava8intermediate-operationsterminal-operations

Quick Answer

A Stream represents a sequence of elements supporting a pipeline of functional-style operations, without storing the elements itself and without modifying the underlying source. Intermediate operations (filter, map, sorted) are lazy and return a new Stream, only actually running once a terminal operation triggers the pipeline. Terminal operations (collect, forEach, reduce, count) consume the stream, produce a final result or side effect, and cannot be followed by any further operations on that stream.

Detailed Answer

A Stream<T> represents a (possibly lazy, possibly infinite) sequence of elements that supports a chain of functional-style operations, typically built from a collection (list.stream()), an array, or a generator. Crucially, a stream doesn't store data itself and doesn't modify its source — it's a pipeline description, evaluated on demand.

Intermediate operations (filter, map, sorted, distinct, limit, ...) return a new Stream, and are lazy — nothing actually executes until a terminal operation is invoked. This laziness lets the pipeline be optimized as a whole (e.g., fusing filter+map into a single pass per element, or short-circuiting with limit).

Terminal operations (collect, forEach, reduce, count, anyMatch, toList) trigger the actual traversal, produce a final result (or side effect), and consume the stream — you cannot reuse or chain further operations onto a stream after a terminal operation runs (attempting to reuses throws IllegalStateException).

List<String> names = List.of("Alice", "Bob", "Charlie", "Dave");

List<String> result = names.stream()   // create
    .filter(n -> n.length() > 3)       // intermediate — lazy, not yet run
    .map(String::toUpperCase)          // intermediate — lazy, not yet run
    .sorted()                          // intermediate — lazy, not yet run
    .collect(Collectors.toList());     // terminal — pipeline actually executes now

Because intermediate operations are lazy, names.stream().filter(...) alone does nothing observable — no exception is even thrown for a filter predicate that would fail on some element, until a terminal operation actually pulls elements through the pipeline. This lazy, pull-based evaluation (each element flows through the entire pipeline one at a time, rather than each stage processing the whole collection before the next) is what makes operations like limit() able to short-circuit an otherwise-infinite stream.