The Streams API

filter, map & forEach

15 min Lesson 3 of 13

filter, map & forEach

Every stream pipeline is built from three kinds of operation: a source (covered in lesson 2), zero or more intermediate operations that transform the stream, and exactly one terminal operation that triggers evaluation and produces a result. This lesson covers the three operations you will use in virtually every pipeline: filter, map, and forEach.

Lazy evaluation — why it matters

Intermediate operations such as filter and map do nothing until a terminal operation is invoked. The stream is a description of what to do, not a loop that is already running. This laziness allows the JVM to fuse operations and avoid unnecessary work — for example, once filter finds the first match when used with findFirst(), the rest of the source is never touched.

Intermediate vs. terminal: filter and map return a new Stream — they are intermediate. forEach returns void — it is terminal. A stream can only be consumed once; after the terminal operation fires, the stream is exhausted.

filter — keeping elements that match a predicate

filter(Predicate<T> predicate) is an intermediate operation that passes only those elements for which the predicate returns true.

import java.util.List; List<Integer> numbers = List.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10); // Keep only even numbers and print each one numbers.stream() .filter(n -> n % 2 == 0) .forEach(System.out::println); // Output: 2 4 6 8 10

The predicate is any expression that returns a boolean. You can compose predicates with Predicate.and(), Predicate.or(), and Predicate.negate() to keep the lambda readable:

import java.util.List; import java.util.function.Predicate; List<String> words = List.of("java", "stream", "api", "filter", "map", "go"); Predicate<String> longerThanThree = s -> s.length() > 3; Predicate<String> startsWithF = s -> s.startsWith("f"); // words longer than 3 chars OR starting with 'f' words.stream() .filter(longerThanThree.or(startsWithF)) .forEach(System.out::println); // Output: java stream filter
Chain multiple filters instead of one complex predicate when readability suffers. The JVM fuses consecutive filters efficiently, so there is no performance reason to cram logic into a single lambda.

map — transforming every element

map(Function<T, R> mapper) is an intermediate operation that applies a function to every element, producing a Stream<R>. The stream type can change — mapping Stream<String> through String::length produces Stream<Integer>.

import java.util.List; List<String> names = List.of("alice", "bob", "charlie", "diana"); // Capitalise every name names.stream() .map(name -> name.substring(0, 1).toUpperCase() + name.substring(1)) .forEach(System.out::println); // Output: Alice Bob Charlie Diana

A more common real-world pattern is extracting a field from an object:

import java.util.List; record Product(String name, double price) {} List<Product> products = List.of( new Product("Laptop", 1200.00), new Product("Mouse", 25.00), new Product("Monitor", 350.00) ); // Extract just the names products.stream() .map(Product::name) // method reference — cleaner than p -> p.name() .forEach(System.out::println); // Output: Laptop Mouse Monitor
Method references (Product::name, String::toUpperCase) are the idiomatic shorthand for a one-argument lambda that calls a single method. Prefer them when the intent is obvious.

Combining filter and map

The real power emerges when you chain the two. Operations execute element-by-element through the pipeline — element 1 passes through filter then map, then element 2, and so on. There is no intermediate list:

import java.util.List; record Product(String name, double price) {} List<Product> products = List.of( new Product("Laptop", 1200.00), new Product("Mouse", 25.00), new Product("Monitor", 350.00), new Product("Keyboard", 75.00) ); // Names of products priced above 100, uppercased products.stream() .filter(p -> p.price() > 100) .map(p -> p.name().toUpperCase()) .forEach(System.out::println); // Output: LAPTOP MONITOR

forEach — consuming every element

forEach(Consumer<T> action) is a terminal operation that executes the action for every element remaining in the stream. It returns void and is typically used for side effects — printing, logging, or writing to an external system.

import java.util.List; List<String> errors = List.of("NullPointerException", "IOException", "TimeoutException"); errors.stream() .filter(e -> e.contains("Exception")) .forEach(e -> System.out.println("[ERROR] " + e)); // Output: // [ERROR] NullPointerException // [ERROR] IOException // [ERROR] TimeoutException
Do not use forEach to build a result. If you find yourself creating an empty list, calling forEach, and adding elements inside the lambda, stop. That is exactly what collect() is for (covered in lesson 4). forEach is for side effects only — mutating shared state inside a stream lambda breaks the stream model and causes bugs with parallel streams.

forEachOrdered — when order matters

For sequential streams the order is deterministic. For parallel streams, forEach does not guarantee encounter order. Use forEachOrdered when order is required and you are also using parallel():

List.of("a", "b", "c", "d", "e") .parallelStream() .forEachOrdered(System.out::println); // always prints a b c d e in order

Putting it all together

Here is a self-contained example that ties all three operations into a realistic scenario:

import java.util.List; record Employee(String name, String department, double salary) {} public class StreamDemo { public static void main(String[] args) { List<Employee> employees = List.of( new Employee("Alice", "Engineering", 95_000), new Employee("Bob", "Marketing", 55_000), new Employee("Charlie", "Engineering", 110_000), new Employee("Diana", "HR", 48_000), new Employee("Eve", "Engineering", 88_000) ); System.out.println("Senior engineers (salary >= 90k):"); employees.stream() .filter(e -> e.department().equals("Engineering")) .filter(e -> e.salary() >= 90_000) .map(e -> e.name() + " — $" + e.salary()) .forEach(System.out::println); // Output: // Senior engineers (salary >= 90k): // Alice — $95000.0 // Charlie — $110000.0 } }

Summary

  • filter(predicate) — intermediate; keeps elements where the predicate is true.
  • map(function) — intermediate; transforms every element, potentially changing the type.
  • forEach(consumer) — terminal; executes a side-effect action and exhausts the stream.
  • Intermediate operations are lazy: they execute only when a terminal operation is called.
  • Reserve forEach for side effects; use collect() when you need a result.