AlgoMaster Logo

Intermediate Operations

High Priority27 min readUpdated September 21, 2026
Listen to this chapter
Unlock Audio

Intermediate operations are the transformations chained on a stream before a terminal operation kicks them off. Each one returns a new stream and runs lazily, so the work happens once the terminal operation pulls. The previous lesson covered how operations are categorized (intermediate vs terminal, stateful vs stateless, short-circuiting). This lesson walks through every common intermediate operation, what its signature looks like, when to use it, and the pitfalls that show up in real code.

filter(Predicate)

filter keeps elements that match a predicate and drops the rest. Its signature is Stream<T> filter(Predicate<? super T> predicate). The output stream has the same element type as the input, just with fewer elements.

The lambda p -> p.stock > 0 is a Predicate<Product>. Anything that returns true stays, anything that returns false is skipped.

Multiple filters can be chained, and the result is the same as combining them with &&. Each filter adds another check per element.

Two filter calls are usually clearer than one filter with a long && chain. The compiler doesn't merge them, but the per-element overhead is small and the readability win is worth it.

Common pitfall: assuming `filter` runs eagerly. It doesn't. The predicate isn't invoked until a terminal operation pulls. A stream pipeline without a terminal operation does no filtering.

There's no collect, count, forEach, or any other terminal call, so the predicate never executes. The stream is created and immediately discarded.

map(Function)

map transforms each element. Its signature is <R> Stream<R> map(Function<? super T, ? extends R> mapper). The output stream has one element for every input element, possibly of a different type.

map is one-to-one. Three products in, three names out. The element type changed from Product to String, which is why the output stream's parameter switched.

A common pattern is extracting a field or computing a derived value.

Each price becomes a Double (boxed). The stream is now Stream<Double>, which is fine for collecting into a List<Double> but inefficient for computing just a sum. Primitive streams handle that case.

Common pitfall: mutating the source element inside `map`. map should be a pure transformation. A mapper that mutates the element sneaks a side effect into a stage that's not designed for it, and parallel streams produce incorrect results.

What's wrong with this code?

Fix: Return a new object instead of mutating.

The original products list stays untouched. That's the functional style streams expect.

mapToInt, mapToLong, mapToDouble, and boxed

Generic streams hold objects. Stream<Integer> boxes every int, which costs an allocation per element and slows down arithmetic. The primitive stream types IntStream, LongStream, and DoubleStream hold raw primitives and have specialized methods like sum, average, min, and max.

mapToInt, mapToLong, and mapToDouble convert a Stream<T> into one of those primitive streams.

Without mapToInt, summing stock would look like products.stream().map(p -> p.stock).reduce(0, Integer::sum), which boxes every value and allocates a fresh Integer per element. mapToInt keeps the primitives raw and uses IntStream.sum(), which is a tight loop.

A Stream<Integer> boxes every value into an Integer object. For tight numeric loops over thousands of elements, prefer mapToInt / IntStream to avoid the allocation churn. The runtime difference is small for short streams and noticeable for long ones.

The reverse direction is boxed. It converts an IntStream back to a Stream<Integer> when methods that only exist on the generic stream are needed (for example, collecting into a List<Integer>).

IntStream.rangeClosed(1, 5) produces a primitive stream of 1, 2, 3, 4, 5. collect(Collectors.toList()) doesn't work directly on an IntStream because Collectors.toList() works on object streams. boxed() wraps each int in an Integer so the rest of the pipeline can treat it as a normal Stream<Integer>.

OperationInputOutputUse case
mapToIntStream<T>IntStreamSum, average, min, max over int values
mapToLongStream<T>LongStreamSame as above for long
mapToDoubleStream<T>DoubleStreamSame as above for double (prices, ratings)
boxedIntStream etc.Stream<Integer> etc.Get back to object streams to use collect, toList, etc.

flatMap(Function)

map is one-to-one. flatMap is one-to-many. Each input element produces a stream of zero or more output elements, and flatMap flattens those streams into a single stream.

The signature is <R> Stream<R> flatMap(Function<? super T, ? extends Stream<? extends R>> mapper). The mapper returns a stream per input element, and flatMap concatenates them.

A common e-commerce shape: a list of carts, where each cart holds a list of items, and the goal is one flat stream of every item across every cart.

Three carts in, five items out. The shape of the output isn't tied to the shape of the input. An empty cart contributes zero items and the flow continues.

A visualization of flatMap:

Three carts enter the flatMap stage. Each one is converted to a stream of items. flatMap concatenates those inner streams and emits one flat sequence of five items.

Compare this with what map would do for the same input.

map produces Stream<Stream<CartItem>> because the mapper returns a stream per element. That's almost never the desired shape. flatMap peels off the outer layer to produce Stream<CartItem>.

Another useful e-commerce shape: one order, many line items. Computing the total revenue across all orders becomes a flat stream of items followed by a sum.

The pipeline reads top to bottom: take orders, fan each one out into its items, convert each item to a price-times-quantity double, sum them. Without flatMap, the alternative is nested loops or stitching together intermediate lists.

Common pitfall: forgetting to flatten. Writing map(cart -> cart.items) instead of flatMap(cart -> cart.items.stream()) produces a Stream<List<CartItem>> and the next stage operates on lists, not items. flatMap has primitive variants too: flatMapToInt, flatMapToLong, flatMapToDouble. They behave the same way but emit primitive streams.

distinct()

distinct removes duplicates. Its signature is Stream<T> distinct(). Two elements are duplicates if Objects.equals says they are, which means distinct relies on the element type's equals and hashCode methods.

Order is preserved. The first occurrence of each value stays, later duplicates are dropped.

distinct works out of the box for String, Integer, and other types where equals and hashCode are already defined. For custom classes, both must be overridden.

What's wrong with this code?

Three tags came in, three tags went out. distinct treated all three as different because Tag doesn't override equals. The default Object.equals only returns true for the same instance, and these are three separate new Tag(...) calls.

Fix: Override equals and hashCode.

Now distinct recognizes two Tag objects with the same name as duplicates. Records (Java 14+) define equals and hashCode automatically, which makes them a clean fit for any stream that uses distinct.

distinct is stateful. It has to remember every element it's seen, usually in a HashSet, to detect duplicates. On a long unordered stream, memory grows with the number of unique elements. On a parallel stream, distinct does extra work to preserve encounter order. When ordering doesn't matter, calling unordered() first gives distinct more freedom.

sorted() and sorted(Comparator)

sorted() returns a stream sorted by the elements' natural ordering. Its signature is Stream<T> sorted(). The element type must implement Comparable, otherwise a ClassCastException fires at runtime.

String implements Comparable<String>, so natural ordering is alphabetical. Integer, Double, and most boxed primitives are also comparable.

For custom types, the overload sorted(Comparator<? super T>) takes an explicit comparator. This is more common in real code because most domain objects don't have an obvious "natural" order.

Comparator.comparingDouble builds a comparator from a key extractor. Chain with thenComparing and reverse with reversed.

The cast on (Product p) helps the compiler with type inference when mixing comparing and thenComparing. Skipping it produces "cannot infer type" errors.

sorted is stateful. It has to buffer the entire stream into memory before it can emit the first element. On an infinite stream, sorted never terminates. On a very large stream, it can exhaust memory. For just the top N elements, sorted followed by limit works (the compiler doesn't optimize this, so the cost is bounded by the stream's size). A heap-backed PriorityQueue may be a better fit when N is small relative to the total.

Common pitfall: sorting unnecessarily. sorted is a heavy operation. To take just the first element, use min with a comparator instead. To render a UI list, sort once at the end of the pipeline, not in the middle where every subsequent operation has to live with sorted data.

peek(Consumer)

peek observes each element as it flows through the stream without changing it. Its signature is Stream<T> peek(Consumer<? super T> action). The consumer runs once per element that passes through.

peek exists for debugging. The Javadoc is explicit about this: "This method exists mainly to support debugging." Production code that uses peek for side effects is fragile, because peek only runs when a terminal operation pulls.

The output interleaves. The pipeline processes elements one at a time, not stage by stage. That's lazy evaluation: each element is pulled through every stage before the next element starts. peek makes this visible, which is its main value when debugging a pipeline that's behaving unexpectedly.

What's wrong with this code?

Fix: This won't add anything to names because there's no terminal operation. peek is part of an intermediate pipeline, and intermediate pipelines don't run on their own. Even adding a terminal operation here would be the wrong pattern: side effects in peek are a code smell. Use map and collect instead.

That's the same intent, expressed properly. The pipeline declares the transformation, and collect materializes the result without anyone reaching outside the stream.

limit(n) and skip(n)

limit(n) short-circuits the stream after the first n elements. skip(n) discards the first n and emits everything after. Both have signature Stream<T> limit(long n) / Stream<T> skip(long n).

Together they're the building blocks of pagination. To grab page 2 with 10 items per page, use .skip(10).limit(10).

The cast to long matters when page * pageSize could overflow int. Small catalogs rarely hit it, but it's the right reflex.

limit is short-circuiting. Once it's emitted n elements, the stream stops pulling from upstream. This makes limit useful on infinite streams.

Stream.iterate(1, n -> n + 1) would generate 1, 2, 3, ... forever. limit(5) cuts it off after five and the terminal operation completes.

skip on an ordered parallel stream is expensive because it has to know which elements are first. On a sequential stream, skip is cheap (it drops elements). When ordering doesn't matter in a parallel stream, call unordered() before skip to let it run faster.

takeWhile(Predicate) and dropWhile(Predicate)

Added in Java 9, takeWhile and dropWhile look like filter at first glance, but they behave very differently.

takeWhile(predicate) emits elements as long as the predicate is true. The first element that returns false stops the stream, and nothing after it is emitted, even if later elements would have matched.

dropWhile(predicate) skips elements as long as the predicate is true. The first element that returns false is emitted, along with everything after it (regardless of whether later elements match).

Both stop deciding once they hit the boundary. That's the key difference from filter, which checks every single element independently.

takeWhile stopped at the first 4 and didn't look further. The trailing 1, 2 were dropped even though they match the predicate, because takeWhile had already stopped. dropWhile skipped the leading 1, 2, 3 and then emitted everything from 4 onward, including the later 1 and 2 that don't match. filter checked each element independently and kept every match.

The shape of the data matters. takeWhile and dropWhile are designed for sequences where the predicate flips at some boundary and stays flipped. Sorted prices, time-ordered events, that kind of thing.

After sorting by price ascending, takeWhile(p -> p.price < 10.0) stops as soon as it hits the first product priced at $10 or more. The remaining products don't even get inspected, which is a real win on long streams.

Common pitfall: using `takeWhile` on an unsorted stream. A predicate without a clean boundary causes takeWhile to cut off early and produce confusing results.

What's wrong with this code?

If the first product is priced at $89.99, takeWhile stops immediately and returns an empty list, even though cheaper products exist later. Fix: sort first, or use filter to capture every match regardless of position.

Chaining Behavior: Order of Operations Matters

Intermediate operations stack in declaration order, and that order changes how much work the pipeline does. Two pipelines that produce the same result can have very different costs depending on which operation comes first.

A simple example: filter first vs map first.

Both produce the same list, but pipeline a runs map on 2 elements (the survivors of the filter). Pipeline b runs map on all 4 elements and then filters. With expensive map work (network calls, big string manipulation, etc.), the difference matters.

The rule of thumb: put filter before map when the filter can reduce the number of elements meaningfully. Put filter before sorted too, because sorting fewer elements is cheaper than sorting more and then throwing some away.

Both pipelines return the same list, sorted by price. The second one sorts everything (including out-of-stock products) and then throws some away. The first one filters first and sorts a smaller set.

The same principle applies to distinct: filtering first reduces the working set for distinct's internal HashSet.

Stateful operations (sorted, distinct) and short-circuiting operations (limit, takeWhile) have higher per-element costs than stateless ones (filter, map, peek). Order them so the cheap stages reduce the working set before the expensive stages run.

Streams Are Immutable: Each Operation Returns a New Stream

Every intermediate operation returns a new Stream and leaves the original source untouched. The functional style of streams means building pipelines, not in-place edits.

The source List<Product> has all 2 products. The filtered stream emits 1 (the Notebook). Nothing about products changed.

One important caveat: a Stream can only be consumed once. After a terminal operation runs, the stream is closed. Reusing it throws IllegalStateException.

This is a common error when code assigns a stream to a variable and uses it twice. To reuse the pipeline, build it from the source again, or collect the intermediate result into a List and stream from there.

Operation Summary Table

OperationSignatureWhat it doesStateful?Short-circuiting?
filterStream<T> filter(Predicate<? super T>)Keeps elements that match the predicateNoNo
map<R> Stream<R> map(Function<? super T, ? extends R>)Transforms each element one-to-oneNoNo
mapToInt / Long / DoubleIntStream mapToInt(ToIntFunction<? super T>) etc.Converts to a primitive streamNoNo
boxedStream<Integer> boxed() (on IntStream) etc.Converts a primitive stream back to an object streamNoNo
flatMap<R> Stream<R> flatMap(Function<? super T, ? extends Stream<? extends R>>)One-to-many: each element produces a stream, all flattenedNoNo
distinctStream<T> distinct()Removes duplicates using equals / hashCodeYesNo
sortedStream<T> sorted() / Stream<T> sorted(Comparator<? super T>)Sorts by natural order or comparatorYesNo
peekStream<T> peek(Consumer<? super T>)Observes each element without changing it (debugging)NoNo
limitStream<T> limit(long n)Truncates to first n elementsNo (sequential)Yes
skipStream<T> skip(long n)Discards first n elementsNo (sequential)No
takeWhileStream<T> takeWhile(Predicate<? super T>)Emits elements until predicate fails, then stopsNoYes
dropWhileStream<T> dropWhile(Predicate<? super T>)Discards elements while predicate is true, then emits the restNoNo

limit is listed as not stateful for sequential streams because it only needs a counter. On a parallel stream it's effectively stateful (and slower) because it has to maintain encounter order.

Quiz

Intermediate Operations Quiz

10 quizzes