> ## Content Index
> Fetch the complete content index at: https://www.ggorantala.dev/llms.txt
> Use this file to discover other available public pages before exploring further.

# How to Filter and Transform a List with Java Streams
- URL: https://www.ggorantala.dev/how-to-filter-transform-list-java-streams/
- Published: 2026-07-22T03:31:00.000Z
- Updated: 2026-10-31T04:32:12.000Z
- Description: Turn a for loop into a Java streams pipeline that filters and transforms a list. Build a gradebook in Java 21, trip every classic mistake, and check it all.
- Author: Gopi Gorantala
- Tags: how-to-tutorials, java-collections, java-streams

## The whole job in eight lines

```java
List<String> topJava = grades.stream()
        .filter(g -> g.course().equals("Java"))
        .filter(Grade::passed)
        .sorted(Comparator.comparingInt(Grade::score).reversed()
                .thenComparing(Grade::student))
        .limit(3)
        .map(g -> g.student() + " (" + g.score() + ")")
        .toList();

```

Run against seven grades, that prints:

```log
top Java 3:   [Asha (92), Chen (77), Dara (77)]

```

That's the pattern this article is about: take a list, keep the elements you want, turn each one into something else, and get a list back. If you want to know how to filter a list with Java streams and not trip on the five or six things that bite everyone once, you're in the right place. You'll build a small gradebook, replace a `for` loop with a pipeline one step at a time, and then break the pipeline on purpose so the exceptions aren't a surprise later.

It's for Java developers who already write loops and know what a lambda is. If lambdas are new, skim [what Java streams are](https://ggorantala.dev/what-are-java-streams/?ref=ggorantala.dev) first and come back. Everything runs on a plain JDK 21\. No database, no container, no framework.

I built this with Gradle as the primary path. I tried Maven first and the cloud box I work from couldn't reach Maven Central, so the Gradle build (which needs no downloads for a project with zero dependencies) won. The whole thing took me a bit over half an hour, and most of that was the forced-failure section, not the code.

## What's on my machine

| Tool                    | Version I used                                                           | Check it with |
| ----------------------- | ------------------------------------------------------------------------ | ------------- |
| JDK                     | OpenJDK 21.0.12.1 (Ubuntu package)                                       | java -version |
| Gradle                  | 8.14.3 (Kotlin DSL, run with \--offline)                                 | gradle -v     |
| Maven                   | 3.9.11 installed, but not used for the build (Maven Central unreachable) | mvn -v        |
| IntelliJ IDEA / Eclipse | Not run for this article; steps below are from the vendors' docs         | Help → About  |

You need Java 16 or newer for `Stream.toList()` and for the `record` this article uses. Java 21 is what I tested, and what I'd run today. If `java -version` says 11 or 8, the `record` won't compile and `toList()` won't exist; I show the exact errors in the troubleshooting section.

The data is invented and hard-coded, so your output will match mine character for character.

## Start the gradebook with Gradle

Create a folder called `grades` and put these files in it. The project tree:

```log
grades/
├── build.gradle.kts
├── settings.gradle.kts
└── src/
    ├── main/java/com/example/grades/
    │   ├── Grade.java
    │   ├── Gradebook.java
    │   └── Main.java
    └── test/java/com/example/grades/
        └── Checks.java

```

`settings.gradle.kts`

```kotlin
rootProject.name = "grades"

```

`build.gradle.kts`

```kotlin
plugins {
    application
}

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(21)
    }
}

// The checks live in src/test/java but run as a plain main(), so give
// them their own source set instead of pulling in a test framework.
sourceSets {
    create("checks") {
        java.srcDir("src/test/java")
        compileClasspath += sourceSets.main.get().output
        runtimeClasspath += sourceSets.main.get().output
    }
}

application {
    mainClass = "com.example.grades.Main"
}

tasks.register<JavaExec>("checks") {
    classpath = sourceSets["checks"].runtimeClasspath
    mainClass = "com.example.grades.Checks"
}

```

Why no JUnit? Because I couldn't download it where I built this. The checks later are ordinary Java with a `main` method, so they run anywhere. When you want JUnit 5 and AssertJ, the pattern carries over directly; there's a [JUnit 5 how-to](https://ggorantala.dev/how-to-write-unit-tests-junit-5-java/?ref=ggorantala.dev) planned for this series.

### In IntelliJ IDEA

*Verified from the vendor docs, not executed by me.* Choose **File → New → Project**, pick **Java** on the left, set **Build system** to **Gradle**, **Gradle DSL** to **Kotlin**, and select a JDK 21\. Name it `grades`, set the group to `com.example`, and create it. Replace the generated build file with the one above. To run `Main`, click the green arrow next to `main` in the editor. The `checks` task shows up in the Gradle tool window under **Tasks → other**.

### In Eclipse

*Also from the docs, not executed.* You need the Buildship Gradle plugin, which ships in the Eclipse IDE for Java Developers package. Use **File → New → Project…**, expand **Gradle**, pick **Gradle Project**, and point it at your folder. Make sure the workspace JRE is 21 under **Window → Preferences → Java → Installed JREs**. I haven't confirmed which Eclipse release first supports Java 21 syntax out of the box, so check the release notes for the version you have.

### From the command line, no build tool

```bash
mkdir out
javac -d out $(find src -name '*.java')
java -cp out com.example.grades.Main

```

On Windows PowerShell, list the files by hand or use `Get-ChildItem -Recurse -Filter *.java`.

### With Maven instead of Gradle

I didn't run this one, so treat it as a sketch. A `pom.xml` with `maven.compiler.release` set to `21`, no dependencies, and the `exec-maven-plugin` for `Main` is all you'd need. Everything else in the article is identical.

## How to filter a list with Java streams: seven steps

Each step adds one method to `Gradebook`. After each one I add a line to `Main` and show the output from my own run, so you can tell which step broke if something goes wrong.

### Step 1: Write the loop you're about to replace

Goal: a data type, a few rows, and the loop version, so you have something to compare against.

`src/main/java/com/example/grades/Grade.java`

```java
package com.example.grades;

import java.time.LocalDate;

public record Grade(String student, String course, int score, LocalDate submitted) {

    public Grade {
        if (score < 0 || score > 100) {
            throw new IllegalArgumentException("score must be 0-100: " + score);
        }
    }

    public boolean passed() {
        return score >= 50;
    }
}

```

A [record](https://ggorantala.dev/how-to-use-java-records/?ref=ggorantala.dev) gives you the constructor, accessors and `equals` for free, and it's immutable, which is exactly what you want to stream over. The compact constructor rejects impossible scores.

`src/main/java/com/example/grades/Gradebook.java`

```java
package com.example.grades;

import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Objects;
import java.util.OptionalDouble;

public class Gradebook {

    private final List<Grade> grades;

    public Gradebook(List<Grade> grades) {
        this.grades = List.copyOf(grades);
    }

    /** Step 1: the loop we are about to replace. */
    public List<Grade> passingWithLoop() {
        List<Grade> result = new ArrayList<>();
        for (Grade g : grades) {
            if (g.passed()) {
                result.add(g);
            }
        }
        return result;
    }
}

```

I've put every import the finished class needs up front. Until you reach the later steps, some are unused, and `javac` doesn't mind. `List.copyOf` takes a defensive copy, the same trick from the [immutable class article](https://ggorantala.dev/how-to-create-immutable-class-java/?ref=ggorantala.dev).

`src/main/java/com/example/grades/Main.java`

```java
package com.example.grades;

import java.time.LocalDate;
import java.util.List;

public class Main {

    static List<Grade> sample() {
        return List.of(
                new Grade("Asha", "Java", 92, LocalDate.of(2026, 9, 3)),
                new Grade("Ben", "Java", 48, LocalDate.of(2026, 9, 4)),
                new Grade("Chen", "Java", 77, LocalDate.of(2026, 9, 10)),
                new Grade("Asha", "SQL", 85, LocalDate.of(2026, 9, 12)),
                new Grade("Dara", "Java", 77, LocalDate.of(2026, 9, 14)),
                new Grade("Ben", "SQL", 64, LocalDate.of(2026, 9, 15)),
                new Grade("Chen", "SQL", 39, LocalDate.of(2026, 9, 20)));
    }

    public static void main(String[] args) {
        var book = new Gradebook(sample());
        System.out.println("loop:         " + book.passingWithLoop().size() + " of 7");
    }
}

```

Checkpoint: run `gradle --offline run` (or `java -cp out com.example.grades.Main`).

```log
loop:         5 of 7

```

Five of seven grades are 50 or higher. That number is your reference for the next step.

### Step 2: Filter with `filter`

Goal: keep the elements that match a condition.

Add this method inside `Gradebook`:

```java
    /** Step 2: keep only the grades that pass the filter. */
    public List<Grade> passing() {
        return grades.stream()
                .filter(Grade::passed)
                .toList();
    }

```

`stream()` opens a pipeline over the list. `filter` takes a predicate, a function that returns `true` or `false`, and passes along only the elements where it's `true`. `toList()` ends the pipeline and hands back a list. `Grade::passed` is a [method reference](https://ggorantala.dev/how-to-use-method-references-java/?ref=ggorantala.dev), shorthand for `g -> g.passed()`.

Add to `main`:

```java
        System.out.println("passing:      " + book.passing().size() + " of 7");

```

```log
passing:      5 of 7

```

Same answer as the loop. The original list is untouched; the stream built a new one.

### Step 3: Transform with `map`

Goal: turn each `Grade` into a different type.

```java
    /** Step 3: transform each element into something else. */
    public List<String> studentNames() {
        return grades.stream()
                .map(Grade::student)
                .distinct()
                .toList();
    }

```

`map` applies a function to every element and passes the results along. The output has the same number of elements as the input, but a different type: `Stream<Grade>` becomes `Stream<String>`. `distinct` then drops repeats, using `equals`, which is why the [equals and hashCode article](https://ggorantala.dev/how-to-implement-equals-hashcode-java/?ref=ggorantala.dev) matters the day you stream your own classes.

```java
        System.out.println("students:     " + book.studentNames());

```

```log
students:     [Asha, Ben, Chen, Dara]

```

Asha, Ben and Chen each appear in two rows. They show up once.

### Step 4: Chain filter, sort, limit and map

Goal: the pipeline from the top of the article, built piece by piece.

```java
    /** Step 4: chain filter, sorted, map and limit. */
    public List<String> topScorers(String course, int count) {
        return grades.stream()
                .filter(g -> g.course().equals(course))
                .filter(Grade::passed)
                .sorted(Comparator.comparingInt(Grade::score).reversed()
                        .thenComparing(Grade::student))
                .limit(count)
                .map(g -> g.student() + " (" + g.score() + ")")
                .toList();
    }

```

Two filters in a row work the same as one filter with `&&`. I prefer two when the conditions mean different things, because each line reads on its own. `sorted` needs a `Comparator`; the `thenComparing` part is the tie-breaker. Without it, Chen and Dara (both 77) would come out in whatever order the sort happened to leave them, and your test would pass on Monday and fail on Tuesday. More on that in the [comparator chaining](https://ggorantala.dev/how-to-chain-comparators-java/?ref=ggorantala.dev) article, which is still on my list.

The order of the stages matters. Filter before you sort, so you sort fewer elements. Map last, because after the `map` the stream no longer holds `Grade` objects and you can't call `score()` on a `String`.

```java
        System.out.println("top Java 3:   " + book.topScorers("Java", 3));

```

```log
top Java 3:   [Asha (92), Chen (77), Dara (77)]

```

Ben's 48 never made it past the filter. If you see that line, the main part of this article is done.

### Step 5: Reduce to a number with `mapToInt`

Goal: when the result isn't a list, switch to a primitive stream.

```java
    /** Step 5: transform to a primitive stream and reduce. */
    public OptionalDouble averageScore(String course) {
        return grades.stream()
                .filter(g -> g.course().equals(course))
                .mapToInt(Grade::score)
                .average();
    }

```

`mapToInt` gives you an `IntStream`, which has `sum`, `average`, `min` and `max` and doesn't box every score into an `Integer`. `average` returns an `OptionalDouble` rather than a `double`, because the average of zero numbers doesn't exist. See the [Optional article](https://ggorantala.dev/how-to-use-optional-java/?ref=ggorantala.dev) if that type is new.

```java
        System.out.println("avg Java:     " + book.averageScore("Java").orElse(0));
        System.out.println("avg Rust:     " + book.averageScore("Rust"));

```

```log
avg Java:     73.5
avg Rust:     OptionalDouble.empty

```

Nobody's taken Rust, so the average is empty and nothing blew up. If you'd written `.getAsDouble()` there, it would have.

### Step 6: Build the filter at runtime

Goal: an optional condition, applied only when the caller passes one.

```java
    /** Step 6: filter with a condition built at runtime. */
    public List<Grade> submittedSince(LocalDate since, String course) {
        var stream = grades.stream().filter(g -> !g.submitted().isBefore(since));
        if (course != null) {
            stream = stream.filter(g -> g.course().equals(course));
        }
        return stream.toList();
    }

```

Each `filter` returns a new stream, so you reassign the variable. `var` keeps the declaration short; the type is `Stream<Grade>`. Nothing has run at that point; the filters are only recorded. The lazy-evaluation section below explains why that matters.

```java
        System.out.println("since 09-12:  " + book.submittedSince(LocalDate.of(2026, 9, 12), null).size());
        System.out.println("SQL since:    " + book.submittedSince(LocalDate.of(2026, 9, 12), "SQL").size());

```

```log
since 09-12:  4
SQL since:    3

```

### Step 7: Clean up dirty input

Goal: survive `null` and blank strings from a legacy source.

```java
    /** Step 7: tolerate null elements from a legacy source. */
    public static List<String> cleanNames(List<String> raw) {
        return raw.stream()
                .filter(Objects::nonNull)
                .map(String::strip)
                .filter(s -> !s.isEmpty())
                .map(s -> s.substring(0, 1).toUpperCase() + s.substring(1).toLowerCase())
                .toList();
    }

```

The `null` filter has to come first. Put `map(String::strip)` before it and the first `null` throws a `NullPointerException` on the spot. Two `map` calls in one pipeline is fine, and sometimes clearer than one long lambda.

```java
        System.out.println("clean:        " + Gradebook.cleanNames(java.util.Arrays.asList(" asha", null, "  ", "BEN ")));

```

```log
clean:        [Asha, Ben]

```

`Arrays.asList` is there because `List.of` refuses `null` elements outright, so you couldn't build the dirty input with it.

## Run both builds and compare

With everything in place, `Main` prints this (Gradle, `gradle --offline -q run`):

```log
loop:         5 of 7
passing:      5 of 7
students:     [Asha, Ben, Chen, Dara]
top Java 3:   [Asha (92), Chen (77), Dara (77)]
avg Java:     73.5
avg Rust:     OptionalDouble.empty
since 09-12:  4
SQL since:    3
clean:        [Asha, Ben]

```

The plain `javac` route gives the same nine lines. Gradle's `BUILD SUCCESSFUL in 913ms` on a warm daemon, for what it's worth. A cold first run takes several seconds longer while Gradle starts its daemon.

## Check the pipelines against the loops they replace

Eyeballing seven rows proves very little. The test I trust most for a refactor like this is the dumb one: write the loop version, write the stream version, feed both a few hundred random lists, and demand identical results.

`src/test/java/com/example/grades/Checks.java`

```java
package com.example.grades;

import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Random;

public class Checks {

    static int passed = 0;
    static int failed = 0;

    static void check(String name, boolean ok) {
        if (ok) { passed++; System.out.println("PASS  " + name); }
        else    { failed++; System.out.println("FAIL  " + name); }
    }

    static void throwsOn(String name, Runnable r, Class<? extends Throwable> expected) {
        try {
            r.run();
            check(name + " (nothing thrown)", false);
        } catch (Throwable t) {
            check(name + " -> " + t.getClass().getSimpleName(), expected.isInstance(t));
        }
    }

    public static void main(String[] args) {
        var book = new Gradebook(Main.sample());

        check("passing keeps 5 of 7", book.passing().size() == 5);
        check("loop and stream agree on the sample", book.passingWithLoop().equals(book.passing()));
        check("passing keeps order", book.passing().get(0).student().equals("Asha"));
        check("studentNames distinct", book.studentNames().equals(List.of("Asha", "Ben", "Chen", "Dara")));
        check("topScorers breaks ties by name",
                book.topScorers("Java", 3).equals(List.of("Asha (92)", "Chen (77)", "Dara (77)")));
        check("topScorers limit 1", book.topScorers("Java", 1).equals(List.of("Asha (92)")));
        check("topScorers unknown course is empty", book.topScorers("Rust", 3).isEmpty());
        check("average Java", book.averageScore("Java").getAsDouble() == 73.5);
        check("average of nothing is empty", book.averageScore("Rust").isEmpty());
        check("null course means all courses", book.submittedSince(LocalDate.of(2026, 9, 12), null).size() == 4);
        check("course narrows the result", book.submittedSince(LocalDate.of(2026, 9, 12), "SQL").size() == 3);
        check("cleanNames drops null and blank",
                Gradebook.cleanNames(java.util.Arrays.asList(" asha", null, "  ", "BEN ")).equals(List.of("Asha", "Ben")));

        throwsOn("result list is immutable", () -> book.passing().add(null), UnsupportedOperationException.class);
        throwsOn("score 101 rejected", () -> new Grade("X", "Java", 101, LocalDate.now()), IllegalArgumentException.class);

        // The stream pipelines must agree with the plain loops they replace.
        var rnd = new Random(42);
        String[] courses = {"Java", "SQL", "Go"};
        boolean allMatch = true;
        for (int round = 0; round < 500; round++) {
            var list = new ArrayList<Grade>();
            int n = rnd.nextInt(30);
            for (int i = 0; i < n; i++) {
                list.add(new Grade("S" + rnd.nextInt(8), courses[rnd.nextInt(3)],
                        rnd.nextInt(101), LocalDate.of(2026, 9, 1).plusDays(rnd.nextInt(30))));
            }
            var gb = new Gradebook(list);

            var loopPassing = new ArrayList<Grade>();
            for (Grade g : list) if (g.score() >= 50) loopPassing.add(g);

            var loopTop = new ArrayList<Grade>();
            for (Grade g : list) if (g.course().equals("SQL") && g.score() >= 50) loopTop.add(g);
            loopTop.sort(Comparator.comparingInt(Grade::score).reversed().thenComparing(Grade::student));
            var loopNames = new ArrayList<String>();
            for (int i = 0; i < Math.min(3, loopTop.size()); i++) {
                loopNames.add(loopTop.get(i).student() + " (" + loopTop.get(i).score() + ")");
            }

            int sum = 0, count = 0;
            for (Grade g : list) if (g.course().equals("Go")) { sum += g.score(); count++; }
            boolean avgOk = count == 0 ? gb.averageScore("Go").isEmpty()
                                       : gb.averageScore("Go").getAsDouble() == (double) sum / count;

            allMatch &= gb.passing().equals(loopPassing)
                    && gb.topScorers("SQL", 3).equals(loopNames)
                    && avgOk;
        }
        check("500 random gradebooks: streams == loops", allMatch);

        System.out.println();
        System.out.println(passed + " passed, " + failed + " failed");
        if (failed > 0) System.exit(1);
    }
}

```

Run `gradle --offline -q checks`. My run, shortened:

```log
PASS  passing keeps 5 of 7
PASS  loop and stream agree on the sample
[... lines cut]
PASS  result list is immutable -> UnsupportedOperationException
PASS  score 101 rejected -> IllegalArgumentException
PASS  500 random gradebooks: streams == loops

15 passed, 0 failed

```

Empty lists are in that random batch too (`nextInt(30)` can return 0), which is where an `average` bug would hide. The seed is fixed, so a failure reproduces.

## Lazy, one element at a time: what a pipeline does

Two facts explain most stream bugs.

First, **nothing runs until a terminal operation**. `filter`, `map`, `sorted` and `limit` only describe work. `toList()`, `count()`, `forEach()` and `average()` start it. I wrote a pipeline with a `println` inside the `filter` and no terminal operation. It printed nothing, because nothing ran.

Second, **elements travel through the whole pipeline one at a time**, not one stage at a time. This is what happens with the stages from step 4 (filter on length greater than three, then map to upper case), on the names `asha`, `ben`, `chen`, `dara`:

```log
filter asha
map    asha
filter ben
filter chen
map    chen
filter dara
map    dara

```

Ben fails the filter and never reaches `map`. If the stages had run one after another, you'd see all four `filter` lines first. That per-element flow is why `limit(3)` can stop early, and why putting an expensive `map` after a cheap `filter` is a real saving. `sorted` is the exception: it has to see every element before it can emit the first one.

If you want the deeper story, [how the Streams API works](https://ggorantala.dev/how-does-streams-api-work/?ref=ggorantala.dev) covers the machinery. For this task, "lazy, per-element, terminal operation starts it" is enough.

Also worth knowing: `Stream.toList()` (Java 16+) returns an unmodifiable list. `Collectors.toList()` makes no promise about the list type, and today it gives you an `ArrayList`. Pick `toList()` unless you really need to add to the result afterwards, in which case say so out loud with `new ArrayList<>(...)`.

## Streams that blew up on me

I forced each of these while building. Every message is verbatim from `java 21.0.12.1`.

| What I did                                                   | What I got                                                                                                      | Cause and fix                                                                                            |
| ------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
| Called .add("x") on the result of toList()                   | UnsupportedOperationException                                                                                   | toList() is unmodifiable. Copy it into an ArrayList if you need to change it.                            |
| Used the same stream twice (s.count(); s.count();)           | IllegalStateException: stream has already been operated upon or closed                                          | A stream is single-use. Call list.stream() again, or keep a Supplier<Stream<T>>.                         |
| Added to the source list inside forEach on its own stream    | ConcurrentModificationException                                                                                 | Don't change the source while you stream it. Collect the new items, then add them after.                 |
| map(String::length) on a list that contains null             | NullPointerException                                                                                            | Filter Objects::nonNull before the map, as in step 7.                                                    |
| Collectors.toUnmodifiableList() with a null element          | NullPointerException                                                                                            | That collector rejects null. toList() accepts it: the result printed \[asha, null\].                     |
| sorted() on a record that isn't Comparable                   | ClassCastException: class Traps$1P cannot be cast to class java.lang.Comparable                                 | Pass a Comparator, as in step 4.                                                                         |
| average().getAsDouble() on an empty IntStream                | NoSuchElementException: No value present                                                                        | Use orElse or isPresent on the OptionalDouble.                                                           |
| Wrote forEach(n -> total += n)                               | error: local variables referenced from a lambda expression must be final or effectively final                   | Lambdas can't mutate a local variable. Use mapToInt(...).sum().                                          |
| List<String> out = ... map(n -> n \* 2).toList() on integers | error: incompatible types: List<Integer> cannot be converted to List<String>                                    | map changed the element type; the declared type must match.                                              |
| Compiled with \--release 11                                  | error: records are not supported in -source 11, and for toList(): error: cannot find symbol ... method toList() | Use Java 16 or newer, or replace toList() with collect(Collectors.toList()) and the record with a class. |

One more that surprised me, and it's the kind of thing that looks like a bug in the JDK. I put a `peek` in front of `count()` over a four-element list and the `peek` ran zero times. Since Java 9, `count()` may skip the pipeline when it can work the size out directly from the source. Never rely on a side effect inside `peek` or `map`; it isn't guaranteed to run. And a related one if you ever hit the duplicate-key crash while collecting to a map, there's a separate write-up: [IllegalStateException: Duplicate key in Collectors.toMap](https://ggorantala.dev/java-illegalstateexception-duplicate-key-collectors-tomap/?ref=ggorantala.dev).

## Streams in real code: a few opinions

- **A loop is still fine.** If the loop body is five lines of mutation, a stream pipeline won't make it better. Reach for streams when the shape is "filter, transform, collect".
- **Keep lambdas short.** If a lambda needs braces, give it a name as a method and use a method reference. `Grade::passed` documents itself in a way `g -> g.score() >= 50` doesn't.
- **No side effects in the middle.** `filter` and `map` should be pure. Collecting into an outside list from a `forEach` works in a sequential stream and breaks the day someone adds `.parallel()`.
- **Don't reach for `parallelStream()` by reflex.** For a few thousand small elements it's usually slower. Measure first.
- **Next up:** grouping and partitioning results is its own article, [how to collect stream results with groupingBy, partitioningBy and toMap](https://ggorantala.dev/how-to-collect-stream-results-java/?ref=ggorantala.dev), and nested lists need [flatMap](https://ggorantala.dev/how-to-use-flatmap-java/?ref=ggorantala.dev).

Version notes: on Java 8 through 15, replace `toList()` with `collect(Collectors.toList())` and `record` with a normal class. From Java 16 on, `toList()` is there. Java 21 doesn't change any of the code in this article.

## Keep these five rules

- Nothing runs until a terminal operation: `toList()`, `count()`, `forEach()`, `average()`.
- Filter first, sort second, map last, so you do the least work and keep the right types.
- A stream is used once. Create a new one from the list when you need another pass.
- Never mutate the source or an outside variable from inside the pipeline.
- When you refactor a loop, keep both versions for a day and make a random-data check prove they agree.

## Questions about filtering lists with streams

**How do I filter a list in Java without changing the original?** Call `list.stream().filter(...).toList()`. The stream reads from the list and builds a new one; the original isn't touched. On Java 15 and older, end with `collect(Collectors.toList())` instead.

**What's the difference between `filter` and `map`?** `filter` keeps or drops elements and the type stays the same. `map` changes every element into something else, so the type usually changes and the count stays the same.

**Can I add to the list that `toList()` returns?** Not with `Stream.toList()` on Java 16 or newer; it throws `UnsupportedOperationException`. If you need a mutable list, wrap it: `new ArrayList<>(stream.toList())`.

**Why does my `filter` with a `println` print nothing?** Because the pipeline has no terminal operation, so it never starts. Add `toList()` or `count()` and the lines appear, one element at a time.

**How do I filter on more than one condition?** Chain several `filter` calls, or combine them with `&&` in one lambda. Both behave the same. Use separate calls when each condition reads as its own rule.

## Sources

- [Stream, Java SE 21 API](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/stream/Stream.html?ref=ggorantala.dev)
- [java.util.stream package summary, Java SE 21](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/stream/package-summary.html?ref=ggorantala.dev)
- [Collectors, Java SE 21 API](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/stream/Collectors.html?ref=ggorantala.dev)
- [JEP 395: Records](https://openjdk.org/jeps/395?ref=ggorantala.dev)
- Page-1 search results I compared against on 2026-10-25: CodeSignal's streams lesson, kodejava.org "How do I filter and map a stream effectively?", a Medium post on filtering with the Stream API, and a DEV Community post on filtering collections. Stack Overflow itself was blocked from where I work, so no thread shaped this article.