Rafael del Nero
Contributing Writer

Java Stream API tutorial: How to create and use Java streams

how-to
Nov 13, 202513 mins

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.

Streaming Data
Credit: Quardia - shutterstock.com

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 ArrayList or HashSet) 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:

  1. filter out "al" and "bob" (since their length isnโ€™t greater than 3),
  2. map the rest to uppercase, and
  3. 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(), or count().

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 immutable List (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.