Get started with Java streams, including how to create streams from Java collections, the mechanics of a stream pipeline, examples of functional programming with Java streams, and more.
You can think of a Java stream as a pipeline through which data flows. Instead of manually writing loops and conditionals to process a list, you tell Java what should happen to each element, and the Java Stream API takes care of how it happens internally.
A Java stream doesnโt hold data. Instead, it operates on an existing data source such as a List, Set, Map, or array. The stream applies a series of operations to the data source.
This article introduces you to Java streams. Youโll learn how to create streams from Java collections, get your first look at a stream pipeline, and see how lambdas, method references, and other functional programming elements work with Java streams. Youโll also learn how to combine collectors and optional chaining with Java streams, and when to use or not use streams in your programs.
Streams versus collections in Java
Many developers get tripped up by the difference between Java streams and Java collections:
- Collections (like
ArrayListorHashSet) are used for storage. They keep data in memory for you to access. - Streams are about behavior. They describe what to do with data, not how to store it.
As an analogy, consider that a collection is the cupboard holding ingredients, whereas a stream is the recipe for making them into a meal.
Streams give Java a functional and declarative feel by describing what to do instead of how to do it.
Why developers love Java streams
Java developers appreciate and use streams for a variety of reasons:
- Cleaner code that replaces nested loops and conditionals.
- Less boilerplate; no more manual for loops.
- Readable logicโstream pipelines read like natural language.
We can begin to see these differences by comparing loops and streams.
Loops vs. streams
Streams often replace traditional loops in Java, and once youโve started using them, itโs hard to go back. Hereโs an example of a classic for loop:
List<String> names = List.of("patrick", "mike", "james", "bill");
List<String> result = new ArrayList<>();
for (String name : names) {
if (name.length() > 4) {
result.add(name.toUpperCase());
}
}
Collections.sort(result);
System.out.println(result);
And here is the Java streams version:
List<String> names = List.of("patrick", "mike", "james", "bill");
List<String> result = names.stream()
.filter(name -> name.length() > 4)
.map(String::toUpperCase)
.sorted()
.toList();
System.out.println(result);
Unlike a loop, the Stream reads almost like English: โTake the names, filter by length, convert to uppercase, sort them, then collect to a list.โ
After completing, the output will be: [JAMES, PATRICK].
Creating Java streams from collections
Streams can start from many sources. Think of all the examples below as ways to โturn on the tap.โ
Hereโs how to create a Stream from a collectionโin this case, a List of names:
List<String> names = List.of("James", "Bill", "Patrick");
Stream<String> nameStream = names.stream();
Hereโs how to create a Stream from a Map:
Map<Integer, String> idToName = Map.of(1, "James", 2, "Bill");
Stream<Map.Entry<Integer, String>> entryStream = idToName.entrySet().stream();
And here is one created from an array:
String[] names = {"James", "Bill", "Patrick"};
Stream<String> nameStream = Arrays.stream(names);
You can also create a Stream using Stream.of():
Stream<Integer> numberStream = Stream.of(1, 2, 3, 4, 5);
Using Stream.of(), you can pass in any kind of value or object to create a Stream. Itโs a simple way to quickly create a stream when you donโt already have a collection or array. Perfect for small, fixed sets of data or quick tests.
Using Stream.generate() (infinite streams)
The Stream.generate() method creates an infinite stream; it keeps producing values while the pipeline requests them:
Stream.generate(() -> "hello")
.forEach(System.out::println);
This Stream never stops printing. Use limit() to control it:
Stream.generate(Math::random)
.limit(5)
.forEach(System.out::println);
Both Stream.generate() and Stream.iterate() can produce infinite sequences. Always limit or short-circuit them to avoid endless execution.
If you need to safely return an empty stream rather than null, use Stream.empty():
Stream<String> emptyStream = Stream.empty();
This avoids null checks and makes methods returning streams safer and cleaner.
Intermediate and lazy stream operations
Streams have intermediate (lazy) and terminal (executing) operations. Together, these two types of operations form your data pipeline.
Intermediate operations (transforming on the way)
Intermediate streams operations donโt trigger execution right away. They just add steps to the recipe:
map(): Transforms each element.filter(): Keeps only elements that match a condition.sorted(): Arranges elements in order.distinct(): Removes duplicates.limit()/skip():Trims the stream.flatMap(): Flattens nested structures (e.g., lists of lists) into one stream.peek(): Lets you look at elements as they pass through (great for debugging/logging, but not for side effects).takeWhile(predicate): Keeps pulling elements until the predicate fails (like a conditional limit).dropWhile(predicate): Skips elements while the predicate is true, then keeps the rest.
Streams are lazy
Streams prepare all their steps first (filtering, mapping, sorting), but nothing happens until a terminal operation triggers processing. This lazy evaluation makes them efficient by processing only whatโs needed.
Take this stream pipeline, for example:
List<String> names = List.of("james", "bill", "patrick", "guy");
names.stream()
.filter(n -> n.length() > 3) // keep names longer than 3 characters
.map(String::toUpperCase) // convert to uppercase
.sorted(); // sort alphabetically
System.out.println("List result: " + names);
The result will be: [james, bill, patrick, guy].
At first glance, it looks like this pipeline should:
- filter out
"al"and"bob"(since their length isnโt greater than 3), - map the rest to uppercase, and
- sort them.
But in reality, the pipeline does none of that.
The reason is that streams in Java are lazy.
- All those calls (
filter,map,sorted) are intermediate operations. - They donโt run immediately. Instead, they โrecord the plan.โ
- The plan only runs when you add a terminal operation like
.toList(),forEach(), orcount().
Since thereโs no terminal operation in the above code, the pipeline is discarded and the original list prints unchanged.
Terminal operations (serving the dish)
Now we can look at the second kind of stream operation. Terminal operations trigger the stream to run and produce a result:
forEach(): Do something with each element.collect(): Gather elements into a collection.toList(): Collect all elements into an immutableList(Javaโฏ16+).reduce(): Fold elements into a single result (sum, product, etc.).count(): How many items?findFirst(): Returns the first element that matches the filtering conditions (useful when order matters).findAny(): Returns any matching element (especially useful in parallel streams where order is not guaranteed).toArray(): Collect results into an array.min(Comparator)/max(Comparator): Find the smallest or largest element based on a comparator.anyMatch(predicate): Does any element match?allMatch(predicate): Do all elements match?noneMatch(predicate): Do no elements match?
Hereโs an example of a stream with terminal operations:
List<String> names = List.of("james", "bill", "patrick", "guy");
List<String> result = names.stream()
.filter(n -> n.length() > 3)
.map(String::toUpperCase)
.sorted()
.toList(); // Terminal operation method triggers action here
System.out.println(result);
In this case, the output will be: [BILL, JAMES, PATRICK].
Streams are single use
Once a stream has been processed, itโs consumed and canโt be reused. A terminal operation closes the stream:
List<String> names = List.of("James", "Bill", "Patrick");
Stream<String> s = names.stream();
s.forEach(System.out::println); // OK
s.count(); // IllegalStateException โ already processed
In this code, the first call pulls all data through the pipeline, and after that itโs closed. Create a new one if needed:
long count = names.stream().count(); // OK: new stream instance
Flow of a stream pipeline
To conclude this section, here is a stream pipeline with both intermediate and terminal streams operations:
List<String> result = names.stream() // Source
.filter(n -> n.length() > 3) // Intermediate operation
.map(String::toUpperCase) // Intermediate operation
.sorted() // Intermediate operation
.toList(); // Terminal operation
Working with collectors
In addition to streams, Java 8 introduced collectors, which you can use to describe how to gather (collect) processed data.
Collecting to a list creates a new unmodifiable list of names longer than three characters. Immutable results make stream code safer and more functional:
List<String> list = names.stream()
.filter(n -> n.length() > 3)
.toList(); // Java 16+
Here, we collect results into a set, automatically removing duplicates. Use a set when uniqueness matters more than order:
Set<String> set = names.stream()
.map(String::toUpperCase)
.collect(Collectors.toSet());
Here, we collect to a Map, where each key is the Stringโs length and each value is the name itself:
Map<Integer, String> map = names.stream()
.collect(Collectors.toMap(
String::length,
n -> n
));
If multiple names share the same length, a collision occurs. Handle it with a merge function:
Map<Integer, String> safeMap = names.stream()
.collect(Collectors.toMap(
String::length,
n -> n,
(a, b) -> a // keep the first value if keys collide
));
Joining strings
Collectors.joining() merges all stream elements into one String using any delimiter you choose. You can use โ |โ, โ ; โ, or even โ\nโ to separate values however you like:
List<String> names = List.of("Bill", "James", "Patrick");
String result = names.stream()
.map(String::toUpperCase)
.collect(Collectors.joining(", "));
System.out.println(result);
The output here will be: BILL, JAMES, PATRICK.
Grouping data
Collectors.groupingBy() groups elements by key (here itโs string length) and returns a Map<Key, List<Value>>:
List<String> names = List.of("james", "linus", "john", "bill", "patrick");
Map<Integer, List<String>> grouped = names.stream()
.collect(Collectors.groupingBy(String::length));
The output will be: {4=[john, bill], 5=[james, linus], 7=[patrick]}.
Summarizing numbers
You can also use collectors for summarizing:
List<Integer> numbers = List.of(3, 5, 7, 2, 10);
IntSummaryStatistics stats = numbers.stream()
.collect(Collectors.summarizingInt(n -> n));
System.out.println(stats);
The output in this case will be: IntSummaryStatistics{count=5, sum=27, min=2, average=5.4, max=10}.
Or, if you want just the average, you could do:
double avg = numbers.stream()
.collect(Collectors.averagingDouble(n -> n));
Functional programming with streams
Earlier, I mentioned that streams combine functional and declarative elements. Letโs look at some of the functional programming elements in streams.
Lambdas and method references
Lambdas define behavior inline, whereas method references reuse existing methods:
names.stream()
.filter(name -> name.length() > 3)
.map(String::toUpperCase)
.forEach(System.out::println);
map() vs. flatMap()
As a rule of thumb:
- Use a
map()when you have one input and want one output. - Use a
flatMap()when you have one input and want many outputs (flattened).
Here is an example using map() in a stream:
List<List<String>> nested = List.of(
List.of("james", "bill"),
List.of("patrick")
);
nested.stream()
.map(list -> list.stream())
.forEach(System.out::println);
The output here will be:
java.util.stream.ReferencePipeline$Head@5ca881b5
java.util.stream.ReferencePipeline$Head@24d46ca6
There are two lines because there are two inner lists, so you need two Stream objects. Also note that hash values will vary.
Here is the same stream with flatMap():
nested.stream()
.flatMap(List::stream)
.forEach(System.out::println);
In this case, the output will be:
james
bill
patrick
For deeper nesting, use:
List<List<List<String>>> deep = List.of(
List.of(List.of("James", "Bill")),
List.of(List.of("Patrick"))
);
List<String> flattened = deep.stream()
.flatMap(List::stream)
.flatMap(List::stream)
.toList();
System.out.println(flattened);
The output in this case will be: [James, Bill, Patrick].
Optional chaining
Optional chaining is another useful operation you can combine with streams:
List<String> names = List.of("James", "Bill", "Patrick");
String found = names.stream()
.filter(n -> n.length() > 6)
.findFirst()
.map(String::toUpperCase)
.orElse("NOT FOUND");
System.out.println(found);
The output will be: NOT FOUND.
findFirst() returns an optional, which safely represents a value that might not exist. If nothing matches, .orElse() provides a fallback value. Methods like findAny(), min(), and max() also return optionals for the same reason.
Conclusion
The Java Stream API transforms how you handle data. You can declare what should happenโsuch as filtering, mapping, or sortingโwhile Java efficiently handles how it happens. Combining streams, collectors, and optionals makes modern Java concise, expressive, and robust. Use streams for transforming or analyzing data collections, not for indexed or heavily mutable tasks. Once you get into the flow, itโs hard to go back to traditional loops.
As you get more comfortable with the basics in this article, you can explore advanced topics like parallel streams, primitive streams, and custom collectors. And donโt forget to practice. Once you understand the code examples here, try running them and changing the code. Experimentation will help you acquire real understanding and skills.


