Skip to content

How to Filter and Transform a List with Java Streams

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.

how-to-tutorials java-collections java-streams
Gopi Gorantala
Reading Progress

On This Page

The whole job in eight lines

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:

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 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:

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

rootProject.name = "grades"

build.gradle.kts

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 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

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

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 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

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.

src/main/java/com/example/grades/Main.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).

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:

    /** 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, shorthand for g -> g.passed().

Add to main:

        System.out.println("passing:      " + book.passing().size() + " of 7");
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.

    /** 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 matters the day you stream your own classes.

        System.out.println("students:     " + book.studentNames());
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.

    /** 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 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.

        System.out.println("top Java 3:   " + book.topScorers("Java", 3));
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.

    /** 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 if that type is new.

        System.out.println("avg Java:     " + book.averageScore("Java").orElse(0));
        System.out.println("avg Rust:     " + book.averageScore("Rust"));
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.

    /** 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.

        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());
since 09-12:  4
SQL since:    3

Step 7: Clean up dirty input

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

    /** 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.

        System.out.println("clean:        " + Gradebook.cleanNames(java.util.Arrays.asList(" asha", null, "  ", "BEN ")));
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):

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

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:

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:

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 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.

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, and nested lists need flatMap.

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

how-to-tutorialsjava-collectionsjava-streams

Staff engineer architecting Kafka and Flink platforms for banking, where a dropped event is a reconciliation break, not a retry. Through javahandbook.com, I share production-tested guidance on building reliable event-driven systems.

Comments