Skip to content
Ameen Abozaid
PublishedBEG-036-L01

Streams vs loops

  • Lesson 1 of 8
  • 12 min read
  • Beginner
On This Lesson 11 topics

Lesson Overview

So far, whenever you wanted to filter or transform a list, you wrote a for loop, made an empty result list, and added items to it by hand. That works, and it will always work. But many loops do the same small job: look at every item, keep some, change some, collect the result. Java has a second way to say this, called the Stream API.

Picture a factory conveyor belt. Items ride past stations. One station throws away items that fail a check, another changes each item, and at the end a worker packs what is left into a box. A stream is that belt: a pipeline of stations over data. A loop describes how to do the work step by step. A stream describes what you want, station by station.

Why this matters: backend code is full of "take these records, keep the ones that match, reshape them, summarize them". Streams are how most modern Java code does it, and you will read them in almost every codebase.

This lesson builds on Collections (BEG-030), generics (BEG-031), and lambdas and method references (BEG-035). A stream is nothing without lambdas, so keep those lessons close.

Learning Objectives

By the end of this lesson you will be able to:

  • Explain what a stream is and what it is not.
  • Name the three parts of every pipeline: source, intermediate operations, terminal operation.
  • Rewrite a simple filter-and-transform loop as a stream, and the reverse.
  • Explain why a stream does not change the collection it came from.
  • Explain why a stream can be used only once.
  • Say, in one sentence, why a loop is not "old" or "wrong".

Written Explanation

What a stream is

A stream is a sequence of elements flowing from a source through a pipeline of operations. Three facts define it:

  • It does not store data. It carries elements from somewhere else, such as a list.
  • It does not change its source. Filtering a stream built from a list gives you a new result and leaves the list as it was.
  • It is consumed once. After the pipeline has run, the stream is finished.

The Java documentation (the java.util.stream package summary) states the first two points directly, and says that once a terminal operation has run, you must get a new stream from the source to traverse the data again.

The three parts of a pipeline

Every stream pipeline has the same shape:

PartWhat it isConveyor beltExample
SourceWhere the elements come fromThe loading docknames.stream()
Intermediate operationsZero or more steps that each give back a new streamStations that sort, filter, or change itemsfilter(...), map(...)
Terminal operationThe one step that produces the final resultThe packing stationtoList(), count()

Read this pipeline from top to bottom, one station per line:

java
List<String> longNames = names.stream()      // source
    .filter(n -> n.length() > 3)             // intermediate: keep long names
    .map(String::toUpperCase)                // intermediate: change each one
    .toList();                               // terminal: pack into a list

The arguments to filter and map are the lambda and method reference you met in BEG-035. filter expects a Predicate (a yes/no question about one item) and map expects a Function (turn one item into another).

The lambda n -> n.length() > 3 is passed in once, but Java applies it to each element as the element reaches that station.

The same job as a loop

Here is the identical task written as a loop:

java
List<String> longNames = new ArrayList<>();
for (String n : names) {
    if (n.length() > 3) {
        longNames.add(n.toUpperCase());
    }
}

Both produce the same list. The difference is in what you spell out:

LoopStream
DescribesHow: create a list, walk it, test, addWhat: keep long names, upper-case them
Extra variablesAn empty result list that you fillNone visible
Reads asA recipe of instructionsA list of stations
Best whenMany steps, changing state, early exitA short chain of filter, change, summarize

Neither is better in general. A stream shines when the job is "a short chain of simple steps over a collection". A loop shines when the job needs counters, flags, early break, or several results at once. Lesson 6 returns to this choice, and the demos at the end of the module practice it.

Where streams come from

The most common source is a collection: list.stream(), set.stream(). A map has no stream() method of its own, but its entrySet(), keySet(), and values() are collections, so you can write map.entrySet().stream(). An array can be turned into a stream with Arrays.stream(array), and a few fixed values with Stream.of(1, 2, 3).

Results are new objects

In the pipeline above, toList() hands back a new list. The original names list is untouched. Since Java 16, toList() returns an unmodifiable list: calling add on it throws UnsupportedOperationException, just like the List.of list you met in BEG-030. If you need a list you can change, wrap it: new ArrayList<>(stream.toList()).

A stream is used once

This is the rule that surprises beginners most:

java
Stream<String> st = names.stream().filter(n -> n.length() > 3);
System.out.println(st.count());   // works: 4
System.out.println(st.count());   // IllegalStateException

The first count() is a terminal operation, so it uses the stream up. The second call fails with the message stream has already been operated upon or closed. The Javadoc says an implementation may throw this exception when it detects reuse and does not promise to detect every case, so never rely on the exception: simply do not reuse a stream. If you need the data again, call names.stream() again. The source list is still there.

Nothing happens until the end

An intermediate operation does not do any work by itself. It only adds a station to the belt. Work starts when a terminal operation asks for the result. This is called laziness, and Lesson 3 explains it fully. For now, remember one consequence: a pipeline without a terminal operation is a belt that was never switched on.

The conveyor-belt analogy, and where it stops

The belt is a good picture for the order of stations. It is less accurate in two places. First, Java does not have to push each item through every station in separate passes; it may fuse the stations so that one pass over the data does everything. Second, a real belt carries physical goods that are gone once packed, while a stream's source list is still there afterwards.

Examples

Example 1: upper-case the long names (simple)

java
import java.util.*;

public class StreamBasics {
    public static void main(String[] args) {
        List<String> names = List.of("Amal", "Bo", "Chen", "Dina", "Eli", "Farid");

        List<String> longNames = names.stream()
            .filter(n -> n.length() > 3)
            .map(String::toUpperCase)
            .toList();

        System.out.println(longNames);
        System.out.println(names);
    }
}

Real output:

text
[AMAL, CHEN, DINA, FARID]
[Amal, Bo, Chen, Dina, Eli, Farid]

The second line proves the source list was not changed.

Example 2: squares of the odd numbers, loop and stream (practical)

java
import java.util.*;

public class OddSquares {
    public static void main(String[] args) {
        List<Integer> nums = List.of(1, 2, 3, 4, 5);

        List<Integer> viaLoop = new ArrayList<>();
        for (int n : nums) {
            if (n % 2 == 1) {
                viaLoop.add(n * n);
            }
        }

        List<Integer> viaStream = nums.stream()
            .filter(n -> n % 2 == 1)
            .map(n -> n * n)
            .toList();

        System.out.println(viaLoop);
        System.out.println(viaStream);
    }
}

Real output:

text
[1, 9, 25]
[1, 9, 25]

Both give the same answer. Compare how many things the loop makes you manage: a result list, a loop variable, a test, an add. In the stream, each line says one thing.

Example 3: the planned mistake, reusing a stream (realistic)

A common reflex is to store a stream in a variable "to use later", for instance to count and then also list the results.

java
import java.util.*;
import java.util.stream.*;

public class ReuseDemo {
    public static void main(String[] args) {
        List<String> names = List.of("Amal", "Bo", "Chen", "Dina", "Eli", "Farid");
        Stream<String> longNames = names.stream().filter(n -> n.length() > 3);

        System.out.println(longNames.count());
        try {
            System.out.println(longNames.toList());
        } catch (IllegalStateException e) {
            System.out.println("Failed: " + e.getMessage());
        }
    }
}

Real output:

text
4
Failed: stream has already been operated upon or closed

The fix is to treat the source as the thing you keep, and build a new stream each time you need one:

java
long howMany = names.stream().filter(n -> n.length() > 3).count();
List<String> theNames = names.stream().filter(n -> n.length() > 3).toList();

Or, if you need both answers, collect once into a list and use the list twice: howMany is then theNames.size().

Backend Insight

Streams appear everywhere in backend code. A service method loads a list of orders from the database, then uses a stream to keep only the paid ones, turn each into a small response object, and return the list. Because the operations are small and named, a teammate can read the pipeline top to bottom like a sentence.

Two habits from this lesson matter on a real server:

  • Do not change the source inside a pipeline. Shared lists in a server can be read by many requests at once, so a pipeline that quietly modifies one causes hard-to-find bugs. Streams are designed for steps that read and produce new results.
  • Keep data in collections, not in streams. A stream is a way of processing data, not of holding it. Pass lists between methods, not streams that someone may or may not already have consumed.

Streams also pair naturally with SQL thinking. Filtering, transforming, and grouping a list in memory mirrors WHERE, SELECT, and GROUP BY in a database, which you will meet in BEG-050. In a real application, the database should do the heavy filtering of millions of rows; streams are for the data that is already in memory.

Common Mistakes

Mistake 1: Forgetting the terminal operation

  • Incorrect: names.stream().filter(n -> n.length() > 3); as a statement, expecting a result.
  • Correct: end with a terminal operation such as .toList() and assign the result.

Without a terminal operation, nothing is computed and nothing is returned that you can use.

Mistake 2: Reusing a consumed stream

  • Incorrect: calling count() and then toList() on the same stream variable.
  • Correct: create a new stream from the source for each pipeline, or collect once and reuse the list.

Mistake 3: Expecting the source to change

  • Incorrect: "I filtered the list, so names is now shorter."
  • Correct: the pipeline gives a new result; the source is unchanged. Assign the result to a variable.

Mistake 4: Trying to modify the result of toList()

  • Incorrect: names.stream().toList().add("X"); which throws UnsupportedOperationException.
  • Correct: copy it with new ArrayList<>(...) if you need a modifiable list.

Mistake 5: Believing streams always beat loops

A stream is not faster or "more professional" by default. Choose the form a teammate can read fastest.

Exercises

Try each exercise yourself before opening the solutions. Use List.of(...) data and run your code.

Level A: Foundation

A1. Label each part of this pipeline as source, intermediate, or terminal: words.stream().filter(w -> w.startsWith("a")).toList().

Show solution A1

A1. words.stream() is the source. filter(...) is intermediate. toList() is terminal.

A2. True or false: after list.stream().filter(...).toList(), the original list has fewer items.

Show solution A2

A2. False. A stream never changes its source, so list keeps all its items. The filtered items are in the new list.

A3. Write a stream pipeline that turns List.of("java", "sql", "git") into a list of the lengths of each word. (Hint: use map.)

Show solution A3

A3.

java
List<Integer> lengths = List.of("java", "sql", "git").stream()
    .map(String::length)
    .toList();   // [4, 3, 3]

Level B: Application

B1. Rewrite this loop as a stream:

java
List<String> out = new ArrayList<>();
for (String w : words) {
    if (w.length() <= 3) {
        out.add(w + "!");
    }
}
Show solution B1

B1.

java
List<String> out = words.stream()
    .filter(w -> w.length() <= 3)
    .map(w -> w + "!")
    .toList();

B2. Rewrite this stream as a loop:

java
List<Integer> out = nums.stream().filter(n -> n > 10).map(n -> n / 2).toList();
Show solution B2

B2.

java
List<Integer> out = new ArrayList<>();
for (int n : nums) {
    if (n > 10) {
        out.add(n / 2);
    }
}

B3. A teammate wrote Stream<String> s = words.stream(); then used s.count() and s.toList(). Explain what happens and give a corrected version.

Show solution B3

B3. count() is terminal, so the stream is used up. The call to toList() then throws IllegalStateException. Corrected: long c = words.stream().count(); and List<String> l = words.stream().toList();, or collect once and use l.size() for the count.

Level C: Challenge

C1. You have List<String> emails and want only those that contain "@", in lower case, as a list that your code can later add to. Write the pipeline. Explain why you needed an extra step.

Show solution C1

C1.

java
List<String> clean = new ArrayList<>(
    emails.stream()
        .filter(e -> e.contains("@"))
        .map(String::toLowerCase)
        .toList());

The extra step is the new ArrayList<>(...) copy, because toList() returns an unmodifiable list, and add on it would throw.

C2. Write the same task, "count the words longer than 4 letters", as a loop and as a stream. Which would you prefer for a program that also needs to print each such word and stop after the tenth? Justify in two sentences.

Show solution C2

C2.

java
int count = 0;
for (String w : words) {
    if (w.length() > 4) { count++; }
}

long count2 = words.stream().filter(w -> w.length() > 4).count();

For the extended task (print each match and stop after ten) I would use the loop: it has a counter, printing, and an early break, which are clearer as explicit steps than forced into a chain.

Mini Challenge

Take any list you have written in an earlier module, for example a list of names or numbers, and write a three-line program that:

  1. builds the list with List.of(...),
  2. produces a new list using one filter and one map,
  3. prints both the original and the new list.

Then write the same job as a loop and compare. Check your work: the original list printed unchanged, the new list is correct, and your pipeline ends with a terminal operation.

Summary & Next Lesson

Key takeaways

  • A stream is a pipeline over data: it does not store data and does not change its source.
  • Every pipeline has a source, zero or more intermediate operations, and one terminal operation.
  • A loop says how; a stream says what. Neither is always better.
  • A stream is used once. Build a new one from the source when you need another.
  • toList() returns a new, unmodifiable list.

Next lesson

The next lesson, "Intermediate operations", opens the toolbox of stations: filter, map, sorted, distinct, limit, and skip, and how to chain them.

Self-check

  1. Name the three parts of a stream pipeline.
  2. Does filter change the original list?
  3. What happens if you call two terminal operations on one stream?
  4. Why can names.stream().toList().add("X") fail?
  5. Give one situation in which a loop is clearer than a stream.
Show answers
  1. Source, intermediate operations, terminal operation.
  2. No. It produces a new stream, and the source is unchanged.
  3. The second one throws IllegalStateException, because the stream was consumed by the first.
  4. toList() returns an unmodifiable list, so add throws UnsupportedOperationException.
  5. When the job needs counters, changing state, several results at once, or an early exit.

References

  • java.util.stream package summary, Java SE 21 API. Supports: streams do not store data or modify their source, laziness of intermediate operations, and that a stream cannot be reused after a terminal operation.
  • Stream, Java SE 21 API. Supports: toList() since Java 16 returns an unmodifiable list, and that an implementation may throw IllegalStateException on reuse without guaranteeing detection.
  • The exact exception message stream has already been operated upon or closed and all output shown were produced by compiling and running the examples with JDK 21. The conveyor-belt analogy is this course's own teaching device.

Practice

Apply this lesson with the module's lab, exercises and quiz.

Lab and exercises Quiz

Marking a lesson complete is optional and you can undo it any time. Your progress stays in this browser.

Continue to next lesson

Type to search levels, phases, modules and lessons.