Skip to content
JavaAgentic

Type at least two characters. Try “RAG”, “pgvector” or “tool calling”.

Stream API Fundamentals: Lazy Pipelines

How a stream pipeline actually executes: why nothing runs until the terminal operation, what short-circuiting really means, stateful versus stateless operations, and why a stream is single-use.

Intermediate5 min readUpdated
On this page

Most stream bugs and most stream interview questions come from one misunderstanding: people picture a stream as a sequence of collection transformations, each producing a full intermediate result. It is not. It is a single pass, pulled element by element, and nothing happens until the end.

Key Takeaways

  • Intermediate operations are lazy and return a stream. Terminal operations trigger execution and return something else.
  • Elements flow vertically: one element through the whole pipeline, then the next.
  • Short-circuiting operations can stop early — findFirst, anyMatch, limit.
  • A stream is single-use. A second terminal operation throws IllegalStateException.
  • Order matters: filter before map means the mapping function runs fewer times.

The three parts of a pipeline

anatomy
List<String> result = orders.stream()          // SOURCE
        .filter(o -> o.total() > 100)          // intermediate — lazy
        .map(Order::customerName)              // intermediate — lazy
        .distinct()                            // intermediate — stateful
        .sorted()                              // intermediate — stateful, full barrier
        .limit(10)                             // intermediate — short-circuiting
        .toList();                             // TERMINAL — executes everything

Delete the last line and the code does nothing at all. The intermediate calls build a linked description of the work and return immediately. This is the single most useful thing to be able to state clearly.

The terminal operation drives the pipeline, pulling elements from the source one at a time.

Vertical, not horizontal

Ordering.java
Stream.of("alpha", "beta", "gamma")
      .filter(s -> { System.out.println("filter: " + s); return s.length() > 4; })
      .map(s -> { System.out.println("  map: " + s); return s.toUpperCase(); })
      .forEach(s -> System.out.println("    out: " + s));
output
filter: alpha
  map: alpha
    out: ALPHA
filter: beta
filter: gamma
  map: gamma
    out: GAMMA

Notice what did not happen: the filter did not run over all three elements before the map started, and beta never reached the map at all. Each element is pushed all the way through before the next one is fetched.

Two consequences follow directly. First, short-circuiting is possible — a terminal operation can stop asking for elements. Second, operation order is a performance decision:

order matters
// map runs 1,000,000 times, filter discards most results
orders.stream().map(this::expensiveEnrichment).filter(Order::isFlagged).toList();
 
// map runs only on the flagged subset
orders.stream().filter(Order::isFlagged).map(this::expensiveEnrichment).toList();

Short-circuiting

only three elements are examined
Optional<Order> first = orders.stream()          // 1,000,000 orders
        .filter(o -> o.total() > 100)
        .findFirst();                             // stops at the first match

The short-circuiting operations are findFirst, findAny, anyMatch, allMatch, noneMatch, limit and takeWhile. They are what make infinite streams usable:

infinite sources
Stream.iterate(1, n -> n * 2).limit(10).toList();          // 1, 2, 4, ... 512
Stream.generate(Math::random).limit(5).toList();
Stream.iterate(1, n -> n < 1000, n -> n * 2).toList();     // Java 9: built-in predicate
 
// Without limit or a predicate, this never terminates.

Note that allMatch on an empty stream returns true — vacuous truth — and anyMatch returns false. That pair is a reliable interview trip-up.

Stateless, stateful and barriers

KindOperationsBehaviour
Statelessfilter, map, flatMap, peekEach element handled independently
Stateful, boundeddistinct, skip, limitNeeds memory of what came before
Stateful, full barriersortedMust consume the entire source before emitting anything

sorted() is the one worth calling out. It breaks the one-element-at-a-time model completely: it buffers everything, sorts, then emits. Putting sorted() before limit(10) on a large stream sorts the whole thing; the JDK does optimise the sorted().limit(n) combination for sized sources, but the general rule — filter and limit before you sort — still holds.

distinct() on an ordered parallel stream is also expensive, because preserving encounter order requires coordination. Calling .unordered() first, when order genuinely does not matter, removes that cost.

Single use

the error everyone hits once
Stream<Order> stream = orders.stream();
long count = stream.count();
List<Order> list = stream.toList();
// IllegalStateException: stream has already been operated upon or closed

A stream is not a collection. It may be backed by a file, a network socket or a generator, none of which can be rewound. Rather than pretend, the API fails loudly.

When you need two results from one traversal, the answers are: collect once and derive both from the collection; use a Collector that computes both (teeing, or a summary statistics collector); or — if the source is a collection — simply call .stream() twice.

one pass, two answers
IntSummaryStatistics stats = orders.stream().mapToInt(Order::quantity).summaryStatistics();
stats.getCount(); stats.getMax(); stats.getAverage();

Spliterator: where the elements come from

Every stream is driven by a Spliterator — "splittable iterator". It does three jobs: advance one element (tryAdvance), split itself in half for parallel processing (trySplit), and report characteristics such as SIZED, ORDERED, DISTINCT and SORTED.

Those characteristics are not decoration; the pipeline uses them to skip work. A stream from a TreeSet reports SORTED, so sorted() becomes a no-op. A stream from a HashSet reports DISTINCT, so distinct() is free. A SIZED source lets toArray allocate exactly once.

a custom source
Stream<String> lines = StreamSupport.stream(
        Spliterators.spliteratorUnknownSize(iterator, Spliterator.ORDERED | Spliterator.NONNULL),
        false);

You rarely write one, but knowing the name and the three responsibilities is a strong signal in an interview, and it is the natural bridge into parallel streams — where trySplit is the whole story.

Side effects and peek

two anti-patterns
// 1. Mutating external state from a stream: not thread-safe, and defeats the model.
List<String> names = new ArrayList<>();
orders.stream().forEach(o -> names.add(o.name()));   // use .map(...).toList()
 
// 2. peek() for anything other than debugging.
orders.stream().peek(this::audit).toList();

peek is documented as existing "mainly to support debugging". Since Java 9 the pipeline may skip it entirely when the element count can be determined without traversal — list.stream().peek(print).count() prints nothing, because count() reads the size directly. Anything with a real side effect belongs in forEach or, better, outside the stream.

The answers to have ready

"Intermediate operations are lazy and return a stream; terminal operations execute the pipeline. Elements are pulled one at a time all the way through, which is what makes short-circuiting possible and why filter before map is faster. A stream is single-use because the source may not be replayable."

Follow-ups to expect: what sorted() does to laziness, why peek sometimes prints nothing, and what happens if you call two terminal operations. All three are answered by the same model.

Frequently Asked Questions

Why does a stream with no terminal operation print nothing?
Because intermediate operations only build a pipeline description. filter and map record what to do and return a new stream; no element is pulled from the source until a terminal operation asks for one. A pipeline ending at map is a completely valid object that has performed no work, which is why a peek used for debugging appears to do nothing when the pipeline is never terminated.
What does IllegalStateException stream has already been operated upon or closed mean?
You called a second terminal operation on the same stream instance, or reused a variable that already holds a consumed stream. A stream is single-use by design: it may be backed by I/O or an infinite generator, so it cannot promise it can be replayed. If you need two results from one source, either collect once and derive both, or create the stream twice from the collection.
Do streams process one element at a time or one operation at a time?
One element at a time, all the way down the pipeline, then the next element. This is why short-circuiting works and why a filter followed by findFirst on a million-element list may only examine three elements. It also means the order of operations matters for performance: filter before map so the map runs on fewer elements.

Related tutorials