Java Streams Beyond map() and filter(): 8 Methods
Most people stop at map(), filter() and collect(). Eight documented Stream and Collectors methods, with Java version tags, replace a lot of loop code.

6 min read · Last updated: October 25, 2026
Java Streams go well beyond map() and filter(). The Java standard library also ships groupingBy, partitioningBy, teeing, flatMapping, takeWhile, a three-argument iterate, mapMulti and toList. Each replaces a hand-written loop with one expression, and each comes with rules the Oracle docs spell out.
Came here from Instagram, LinkedIn, X or YouTube? Here is the full source list you came for. Everything below is built from these, nothing else.
The post that prompted this
The docs
Key takeaways
Collectors.teeing(Java 12) feeds every element to two collectors and merges the results.takeWhile,dropWhile,flatMapping,filteringand the three-argumentiterateall arrived in Java 9.Stream.toList()andmapMultiarrived in Java 16.toList()returns an unmodifiable list.takeWhileon an unordered stream is nondeterministic. Only ordered streams give you the longest matching prefix.- The docs discourage side effects in stream operations. Collect with
toList()instead offorEach(list::add).
What happened?
On Medium, Sanid Andwate published "Java Streams in Practice: Beyond map() and filter()". Its teaser says Java Streams are easy to start using: you can filter a collection, transform its elements and collect the result in a few lines.
I could not open the full post, because Medium returned an HTTP 403 to my fetch. So I am not summarising it. I took its theme, what comes after map() and filter(), and checked every method below against Oracle's own Java SE 22 documentation.
Why does this matter to a developer?
Most stream code stops at filter, map and collect. The next step in the same API removes the loops that remain: grouping, splitting, early exit, and two results from one pass. Learning it means less boilerplate, and it means knowing the rules that make streams safe.
The Java docs are blunt about one of them. They say side effects in stream operations are "in general, discouraged" because they can break the statelessness requirement. Knowing the richer methods is how you avoid reaching for them.

What changes in practice?
Eight methods cover most of it. The table shows what each one does and when it arrived, per the Oracle Java SE 22 docs.
| Method | What it does | Since |
|---|---|---|
Collectors.groupingBy | Groups elements by a classification function into a Map | 1.8 (class) |
Collectors.partitioningBy | Splits elements by a predicate into Map<Boolean, List<T>> | 1.8 (class) |
Collectors.teeing | Runs two collectors on the same elements, then merges | 12 |
Collectors.flatMapping / filtering | Map or filter inside a downstream collector | 9 |
Stream.takeWhile / dropWhile | Take or skip the leading run that matches | 9 |
Stream.iterate(seed, hasNext, next) | A stream that behaves like a for loop | 9 |
Stream.mapMulti | Replace each element with zero or more elements | 16 |
Stream.toList() | Unmodifiable list in encounter order | 16 |
The docs list groupingBy and partitioningBy under the Collectors class, which is Since 1.8.
How do you group and split a stream?
Use groupingBy to group elements by a key, and partitioningBy to split them in two by a predicate. Per the docs, groupingBy returns a Map keyed by your classification function. partitioningBy always returns both a true and a false key, even when a side is empty.
List<String> words = List.of("java", "go", "rust", "c", "kotlin");
Map<Integer, Long> byLength = words.stream()
.collect(Collectors.groupingBy(String::length, Collectors.counting()));
Map<Boolean, List<String>> longOrShort = words.stream()
.collect(Collectors.partitioningBy(w -> w.length() > 3));
That "always both keys" rule matters. You can call longOrShort.get(false) without a null check. If a partition has no elements, the docs say its value is an empty List.
What is Collectors.teeing?
Collectors.teeing, added in Java 12, returns a collector built from two downstream collectors. The docs say every element goes to both, and a merge function combines their results. It gives you two answers from one pass over the data, without a temporary list.
String summary = words.stream().collect(Collectors.teeing(
Collectors.counting(),
Collectors.joining(", "),
(count, joined) -> count + " words: " + joined));
Before teeing, this usually meant collecting into a list first, then streaming it twice.
How do flatMapping and filtering work inside groupingBy?
Collectors.flatMapping and Collectors.filtering, both Since 9, adapt a downstream collector. filtering accumulates an element only if a predicate returns true. flatMapping applies a flat mapping function to each element before accumulation. They apply per group, which a plain filter() before groupingBy cannot do.
The difference is visible with empty groups. Filtering before grouping removes a key entirely. Filtering inside the group keeps the key with an empty collection.
Map<Integer, List<String>> longOnly = words.stream().collect(
Collectors.groupingBy(String::length,
Collectors.filtering(w -> w.startsWith("k"), Collectors.toList())));
I am inferring the "key stays" behaviour from how a downstream collector works, not from a quoted line in the docs, so run it on your own data before you rely on it.
When should you use takeWhile and dropWhile?
takeWhile and dropWhile, Since 9, work on the leading run of a stream. For an ordered stream, takeWhile returns the longest prefix that matches the predicate. dropWhile returns what remains after dropping that prefix. They stop early, unlike filter(), which tests every element.
There is a trap. For an unordered stream, the docs say that if only some elements match, the behaviour is nondeterministic. takeWhile is then free to take any subset of the matches, including none.
List<Integer> sorted = List.of(1, 2, 3, 10, 4, 5);
List<Integer> head = sorted.stream().takeWhile(n -> n < 5).toList();
Here head is [1, 2, 3], because the run stops at 10. A filter(n -> n < 5) would also have kept 4.
How does the three-argument iterate replace a for loop?
Stream.iterate(seed, hasNext, next), Since 9, returns a sequential, ordered stream that ends as soon as hasNext returns false. The docs say it should produce the same sequence as the equivalent for (T i = seed; hasNext.test(i); i = next.apply(i)) loop.
List<Integer> powers = Stream.iterate(1, n -> n < 200, n -> n * 2).toList();
The older two-argument iterate is infinite and needs limit(). The three-argument form has its own stop condition.
When is mapMulti better than flatMap?
mapMulti, Since 16, replaces each element with zero or more elements through a consumer you call. The docs say it is preferable to flatMap when you replace each element with a small number of results, because it avoids creating a new Stream for every element.
List<Integer> doubledOdds = Stream.of(1, 2, 3, 4, 5)
.<Integer>mapMulti((n, sink) -> {
if (n % 2 == 1) { sink.accept(n); sink.accept(n); }
})
.toList();
For large or lazy inner collections, flatMap is still the better fit.
What does Stream.toList() return?
Stream.toList(), Since 16, accumulates elements into a list in encounter order. The returned list is unmodifiable, and calling any mutator throws UnsupportedOperationException, per the docs. If you need a mutable result, the docs point you to Collectors.toCollection(Supplier).
The package summary uses it to show the right pattern. Instead of adding to an outside ArrayList inside forEach, it chains filter(...) into toList().
What are the common mistakes with Java Streams?
The docs name four. Avoid them and most stream bugs go away.
| Mistake | What the docs say |
|---|---|
Mutating outside state in forEach | Side effects are discouraged and can break statelessness |
| Stateful lambdas | Results "may be nondeterministic or incorrect" |
| Reusing a stream | A stream should be operated on only once and may throw IllegalStateException |
| Modifying the source mid-pipeline | Non-interference applies to all pipelines, not just parallel ones |
One more from the docs: peek "exists mainly to support debugging". Do not put real logic in it.
What should you do this week?
- Search your codebase for
forEachcalls that add to a list or map. Rewrite each withtoList()or a collector. - Find any place that builds a
Map<Boolean, ...>by hand. Swap it forpartitioningBy. - Find any place that streams a list twice for two results. Try
teeing. - Replace one
for (int i = ...; cond; i = ...)loop that builds a list with three-argumentiterate. - Check which Java version your project targets. Everything above works from Java 16 onward, and most of it from Java 9.
FAQ
What Java version do I need for teeing, mapMulti and toList()?
Collectors.teeing needs Java 12. Stream.mapMulti and Stream.toList() need Java 16. takeWhile, dropWhile, flatMapping, filtering and the three-argument iterate need Java 9, according to the Oracle Java SE 22 documentation.
Is a Java stream faster than a for loop?
This article does not make that claim, and the Oracle docs I used do not either. Streams are about expressing the pipeline clearly. If speed matters, measure your own workload.
Can I reuse a Java stream?
No. The docs say a stream should be operated on only once, and an implementation may throw IllegalStateException if it detects reuse. Create a new stream from the source each time.
Does parallel change the result of a stream?
It should not. The package summary says that, except for explicitly nondeterministic operations such as findAny(), serial or parallel execution should not change the result. That depends on your functions being stateless and, for reduce, associative.
Pick one loop in your code this week and rewrite it with a method from the table, then try the free tools on Art of Code. For more on the craft side, read Code Was Never the Hard Part, or set up your editor with 10 VS Code Extensions Every Developer Needs in 2026.
Written by Adithya Guttha, Founder of Art of Code.
Sign in to like and save posts.
Keep reading



Discussion
Comments (0)
Want to join the conversation?
Sign in to commentNo comments yet. Be the first to comment!