Java Streams Tutorial: Filter, Map, and Collect Explained

by Didin J. on Sep 11, 2025 Java Streams Tutorial: Filter, Map, and Collect Explained

Learn Java Streams with practical examples. Master filter(), map(), and collect() to write cleaner, faster, and more readable Java code.

Since Java 8, the Streams API has become one of the most powerful tools for handling data in a functional and declarative style. Instead of writing long loops and conditionals, streams allow you to process collections of data (like lists, sets, or arrays) with clean, readable, and expressive code.

At its core, a stream represents a sequence of elements that can be processed in parallel or sequentially. With streams, you can filter, transform, and collect data in just a few lines of code.

In this tutorial, we’ll focus on three of the most commonly used operations:

  • filter() – Select elements that match a given condition.

  • map() – Transform each element into another form.

  • collect() – Gather the processed elements into a collection, string, or another result container.

By mastering these three methods, you’ll be able to replace a lot of boilerplate looping logic with elegant stream operations. Whether you’re working with simple lists of numbers or more complex objects, streams can help make your code more concise and maintainable.


Getting Started with Streams

Before diving into filter(), map(), and collect(), let’s first understand how to create a stream. A stream can be created from different data sources such as collections, arrays, or even generated data. The most common way is from a collection like List or Set.

Here’s the basic syntax:

import java.util.Arrays;
import java.util.List;
import java.util.stream.Stream;

public class StreamExample {
    public static void main(String[] args) {
        // Creating a stream from a List
        List<String> names = Arrays.asList("Alice", "Bob", "Charlie", "David");

        Stream<String> nameStream = names.stream();

        // Print elements using forEach (terminal operation)
        nameStream.forEach(System.out::println);
    }
}

Key Points:

  • names.stream() creates a sequential stream from the list.

  • Streams don’t store data; they operate on the source (like the list).

  • Once a stream is consumed with a terminal operation (like forEach), it cannot be reused.

You can also create streams directly without a collection:

import java.util.stream.Stream;

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

        numbers.forEach(System.out::println);
    }
}

Streams can also be created from arrays:

import java.util.Arrays;

public class StreamFromArray {
    public static void main(String[] args) {
        String[] fruits = {"Apple", "Banana", "Orange"};

        Arrays.stream(fruits)
              .forEach(System.out::println);
    }
}

Now that you know how to create and use a simple stream, we can move on to applying operations like filtering.


Using filter()

The filter() method is an intermediate operation that allows you to select elements from a stream based on a condition. It takes a predicate (a function that returns true or false) and only keeps elements that satisfy the condition.

Example 1: Filtering Even Numbers

import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;

public class FilterEvenNumbers {
    public static void main(String[] args) {
        List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);

        List<Integer> evenNumbers = numbers.stream()
                                           .filter(n -> n % 2 == 0) // keep only even numbers
                                           .collect(Collectors.toList());

        System.out.println("Even Numbers: " + evenNumbers);
    }
}

Output:

Even Numbers: [2, 4, 6, 8, 10]

Example 2: Filtering Strings

import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;

public class FilterNames {
    public static void main(String[] args) {
        List<String> names = Arrays.asList("Alice", "Bob", "Charlie", "David", "Alex");

        List<String> namesStartingWithA = names.stream()
                                               .filter(name -> name.startsWith("A"))
                                               .collect(Collectors.toList());

        System.out.println("Names starting with A: " + namesStartingWithA);
    }
}

Output:

Names starting with A: [Alice, Alex]

Key Points about filter():

  • It does not modify the original collection.

  • It creates a new stream containing only the matching elements.

  • You can chain multiple filter() calls for more complex conditions.

Example:

List<Integer> result = numbers.stream()
                              .filter(n -> n > 2)
                              .filter(n -> n < 8)
                              .collect(Collectors.toList());

Now that we’ve filtered elements, the next step is to learn how to transform them using map().


Using map()

The map() method is another intermediate operation that transforms each element of a stream into something else. It takes a function as a parameter and applies it to every element in the stream, returning a new stream of transformed elements.

Example 1: Transforming Strings to Uppercase

import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;

public class MapUppercase {
    public static void main(String[] args) {
        List<String> names = Arrays.asList("alice", "bob", "charlie", "david");

        List<String> uppercasedNames = names.stream()
                                            .map(String::toUpperCase) // transform each string
                                            .collect(Collectors.toList());

        System.out.println("Uppercased Names: " + uppercasedNames);
    }
}

Output:

Uppercased Names: [ALICE, BOB, CHARLIE, DAVID]

Example 2: Extracting Object Fields

Streams are especially powerful when working with objects. For instance, let’s say we have a User class:

class User {
    private String name;
    private int age;

    public User(String name, int age) {
        this.name = name;
        this.age = age;
    }

    public String getName() { return name; }
    public int getAge() { return age; }
}

Now, suppose we have a list of users and want to extract just their names:

import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;

public class MapObjectFields {
    public static void main(String[] args) {
        List<User> users = Arrays.asList(
            new User("Alice", 30),
            new User("Bob", 25),
            new User("Charlie", 35)
        );

        List<String> userNames = users.stream()
                                      .map(User::getName) // extract name field
                                      .collect(Collectors.toList());

        System.out.println("User Names: " + userNames);
    }
}

Output:

User Names: [Alice, Bob, Charlie]

Key Points about map():

  • It transforms each element into another type or form.

  • The number of elements stays the same, but their values may change.

  • You can chain map() with filter() and other operations for complex data transformations.

Next, we’ll learn how to gather results using the collect() method.


Using collect()

The collect() method is a terminal operation in the Streams API. It gathers the elements of a stream into a collection or another result container. The most common way to use it is with the Collectors utility class, which provides many ready-to-use collectors.

Example 1: Collecting into a List

import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;

public class CollectToList {
    public static void main(String[] args) {
        List<String> names = Arrays.asList("Alice", "Bob", "Charlie");

        List<String> result = names.stream()
                                   .map(String::toUpperCase)
                                   .collect(Collectors.toList());

        System.out.println("Collected List: " + result);
    }
}

Output:

Collected List: [ALICE, BOB, CHARLIE]

Example 2: Collecting into a Set

If you want unique elements, you can collect them into a Set:

import java.util.Arrays;
import java.util.Set;
import java.util.stream.Collectors;

public class CollectToSet {
    public static void main(String[] args) {
        List<String> names = Arrays.asList("Alice", "Bob", "Alice", "Charlie");

        Set<String> uniqueNames = names.stream()
                                       .collect(Collectors.toSet());

        System.out.println("Collected Set: " + uniqueNames);
    }
}

Output (order may vary):

Collected Set: [Alice, Bob, Charlie]

Example 3: Joining Strings

You can also collect stream elements into a single string:

import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;

public class CollectJoining {
    public static void main(String[] args) {
        List<String> names = Arrays.asList("Alice", "Bob", "Charlie");

        String joined = names.stream()
                             .collect(Collectors.joining(", "));

        System.out.println("Joined String: " + joined);
    }
}

Output:

Joined String: Alice, Bob, Charlie

Key Points about collect():

  • It’s a terminal operation (the stream ends here).

  • Can output different data structures (List, Set, Map, String).

  • Very flexible when combined with other collectors like groupingBy, partitioningBy, and counting.

Now that we know how to collect results, we can combine everything we’ve learned: filtering, mapping, and collecting into one pipeline.


Combining filter(), map(), and collect()

The true power of Java Streams comes from chaining multiple operations together. You can filter data, transform it, and then collect the results — all in a single pipeline. This makes your code concise, readable, and efficient.

Example: Filtering Users by Age and Collecting Names

Let’s reuse the User class:

class User {
    private String name;
    private int age;

    public User(String name, int age) {
        this.name = name;
        this.age = age;
    }

    public String getName() { return name; }
    public int getAge() { return age; }
}

Now, suppose we want to get the names of all users older than 25:

import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;

public class StreamPipelineExample {
    public static void main(String[] args) {
        List<User> users = Arrays.asList(
            new User("Alice", 30),
            new User("Bob", 22),
            new User("Charlie", 35),
            new User("David", 19),
            new User("Eve", 28)
        );

        List<String> adultNames = users.stream()
                                       .filter(user -> user.getAge() > 25) // keep users older than 25
                                       .map(User::getName)                 // extract only names
                                       .collect(Collectors.toList());      // collect into a list

        System.out.println("Adult Users: " + adultNames);
    }
}

Output:

Adult Users: [Alice, Charlie, Eve]

Key Points:

  • The operations are executed in order:

    1. filter() removes unwanted elements.

    2. map() transforms remaining elements.

    3. collect() gathers results into a collection.

  • The pipeline is lazy: nothing runs until the terminal operation (collect) is called.

This pattern is very common in real-world applications — for example, filtering product lists, transforming database query results, or formatting API responses.


Advanced Usage & Best Practices

Java Streams are powerful, but to use them effectively, you need to understand a few advanced concepts and best practices.

1. Lazy Evaluation

Streams in Java are lazy. This means intermediate operations (filter, map, etc.) are not executed immediately. Instead, they’re only processed when a terminal operation (like collect, forEach, or count) is invoked.

Example:

import java.util.Arrays;
import java.util.List;

public class LazyEvaluation {
    public static void main(String[] args) {
        List<String> names = Arrays.asList("Alice", "Bob", "Charlie");

        names.stream()
             .filter(name -> {
                 System.out.println("Filtering: " + name);
                 return name.startsWith("A");
             })
             .map(name -> {
                 System.out.println("Mapping: " + name);
                 return name.toUpperCase();
             })
             .forEach(System.out::println); // terminal operation
    }
}

Output:

Filtering: Alice
Mapping: Alice
ALICE
Filtering: Bob
Filtering: Charlie

Notice how operations are applied one element at a time — not all at once.

2. Parallel Streams

If you’re working with large datasets, you can use parallel streams to process elements in multiple threads, leveraging multi-core CPUs:

import java.util.stream.IntStream;

public class ParallelStreamExample {
    public static void main(String[] args) {
        int sum = IntStream.rangeClosed(1, 1_000_000)
                           .parallel() // enable parallel processing
                           .sum();

        System.out.println("Sum: " + sum);
    }
}

When to use: Parallel streams can speed up CPU-bound operations on large datasets.

⚠️ When to avoid:

  • For small datasets (parallel overhead > benefit).

  • When order matters (parallel streams may reorder results).

  • When working with shared mutable state, it may cause race conditions

3. When Not to Use Streams

While streams make code elegant, they’re not always the best choice:

  • Performance-critical code: Traditional loops may be faster for small or performance-sensitive tasks.

  • Complex logic: If the stream pipeline becomes too long and unreadable, a simple loop may be clearer.

  • Debugging: Debugging streams can be trickier compared to plain loops.

Best Practice: Use streams when they make code more readable and maintainable, but don’t force them everywhere. Balance clarity with efficiency.


Conclusion

In this tutorial, we explored how Java Streams simplify data processing by replacing verbose loops with clean, declarative code. We focused on three of the most commonly used methods:

  • filter() – to select elements that match specific conditions.

  • map() – to transform elements into another form.

  • collect() – to gather the processed results into collections or strings.

We also combined these methods to build powerful pipelines, learned about lazy evaluation, experimented with parallel streams, and discussed best practices for when to use (or avoid) streams.

By mastering these basics, you can start writing more concise, readable, and efficient Java code. Whether you’re filtering a list of users, transforming object fields, or aggregating data, the Streams API provides a flexible and modern approach.

💡 Next steps: Try experimenting with other stream operations like flatMap(), reduce(), groupingBy(), and counting() to deepen your knowledge and unlock even more possibilities with streams.

You can get the full source code on our GitHub.

That's just the basics. If you need more in-depth learning about Java and Spring Framework, you can take the following cheap course:

Thanks!