Streams & Optional

Java · Lesson 24 of 32 7 min read Updated

A lot of everyday code follows the same pattern: take a collection, keep only some items, transform them, and combine the result into a total, a list or a report. With loops, you write the mechanics every time: a counter, an if, an add. A stream lets you describe what you want as a pipeline of steps, and Java does the looping for you.

In this lesson you will build stream pipelines with the most useful operations, group and summarise data with collectors, and use Optional, the type Java uses when a result might be missing. You need lambdas and method references from Lambdas & Functional Interfaces.

Your first stream pipeline

A stream pipeline has three parts:

  1. A source: list.stream(), Stream.of(...), Arrays.stream(array) or IntStream.range(a, b).
  2. Zero or more intermediate operations, each returning a new stream: filter, map, sorted, distinct, limit, skip.
  3. One terminal operation that produces the result: count, toList, forEach, sum, collect, findFirst, anyMatch.
Java
import java.util.List;

public class First {
    public static void main(String[] args) {
        List<Integer> marks = List.of(67, 92, 45, 88, 31, 74, 92);

        // Loop version
        int count = 0;
        for (int m : marks) {
            if (m >= 60) {
                count++;
            }
        }
        System.out.println("Loop: " + count + " first-division marks");

        // Stream version
        long passed = marks.stream()
                           .filter(m -> m >= 60)
                           .count();
        System.out.println("Stream: " + passed + " first-division marks");

        List<String> report = marks.stream()
                .filter(m -> m >= 60)
                .distinct()
                .sorted()
                .map(m -> m + "%")
                .toList();
        System.out.println(report);
        System.out.println("Original list unchanged: " + marks);
    }
}
Output
Loop: 5 first-division marks
Stream: 5 first-division marks
[67%, 74%, 88%, 92%]
Original list unchanged: [67, 92, 45, 88, 31, 74, 92]

Read the second pipeline from top to bottom like a recipe: keep marks of 60 and above, remove duplicates, sort, turn each number into text with a percent sign, collect into a list. filter takes a Predicate, map takes a Function, and toList() (Java 16) returns an unmodifiable list.

Three facts about streams:

  • A stream never changes its source; the original list is untouched.
  • Streams are lazy: nothing runs until the terminal operation is called.
  • A stream can be used only once. Calling a second terminal operation on the same stream throws IllegalStateException: stream has already been operated upon or closed. Create a new stream from the source instead.

Common operations

Java
import java.util.List;
import java.util.stream.IntStream;

public class Ops {
    record Order(String customer, String city, int amount) {}

    public static void main(String[] args) {
        List<Order> orders = List.of(
            new Order("Aarav", "Jaipur", 1250),
            new Order("Meera", "Kochi", 480),
            new Order("Kabir", "Jaipur", 2999),
            new Order("Isha", "Pune", 760),
            new Order("Rohan", "Kochi", 1999));

        int total = orders.stream().mapToInt(Order::amount).sum();
        double average = orders.stream().mapToInt(Order::amount).average().orElse(0);
        System.out.println("Total: Rs " + total + ", average: Rs " + average);

        List<String> bigSpenders = orders.stream()
                .filter(o -> o.amount() >= 1000)
                .sorted((a, b) -> b.amount() - a.amount())
                .map(Order::customer)
                .toList();
        System.out.println("Big spenders: " + bigSpenders);

        System.out.println("Any from Pune? " + orders.stream().anyMatch(o -> o.city().equals("Pune")));
        System.out.println("All above Rs 400? " + orders.stream().allMatch(o -> o.amount() > 400));

        int product = IntStream.rangeClosed(1, 5).reduce(1, (a, b) -> a * b);
        System.out.println("5! = " + product);
        System.out.println(IntStream.range(0, 5).boxed().toList());
    }
}
Output
Total: Rs 7488, average: Rs 1497.6
Big spenders: [Kabir, Rohan, Aarav]
Any from Pune? true
All above Rs 400? true
5! = 120
[0, 1, 2, 3, 4]
  • mapToInt turns a stream of objects into an IntStream, a special stream of int values with sum, average, min and max. average() returns an OptionalDouble, because an empty stream has no average; orElse(0) supplies a fallback.
  • anyMatch, allMatch and noneMatch answer yes/no questions and stop as soon as the answer is known.
  • reduce(start, operator) combines all elements into one value.
  • IntStream.range(0, 5) produces 0 to 4; rangeClosed(1, 5) includes 5. boxed() converts ints to Integers so they can go into a list.

Collectors: grouping and joining

The terminal operation collect with the helpers in java.util.stream.Collectors builds richer results: maps of groups, totals per group, or one joined string.

Java
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.stream.Collectors;

public class Collect {
    record Order(String customer, String city, int amount) {}

    public static void main(String[] args) {
        List<Order> orders = List.of(
            new Order("Aarav", "Jaipur", 1250),
            new Order("Meera", "Kochi", 480),
            new Order("Kabir", "Jaipur", 2999),
            new Order("Isha", "Pune", 760),
            new Order("Rohan", "Kochi", 1999));

        Map<String, Long> perCity = orders.stream()
                .collect(Collectors.groupingBy(Order::city, TreeMap::new, Collectors.counting()));
        System.out.println(perCity);

        Map<String, Integer> salesPerCity = orders.stream()
                .collect(Collectors.groupingBy(Order::city, TreeMap::new,
                         Collectors.summingInt(Order::amount)));
        System.out.println(salesPerCity);

        Map<Boolean, List<String>> split = orders.stream()
                .collect(Collectors.partitioningBy(o -> o.amount() >= 1000,
                         Collectors.mapping(Order::customer, Collectors.toList())));
        System.out.println(split);

        String names = orders.stream()
                .map(Order::customer)
                .sorted()
                .collect(Collectors.joining(", ", "Customers: ", "."));
        System.out.println(names);
    }
}
Output
{Jaipur=2, Kochi=2, Pune=1}
{Jaipur=4249, Kochi=2479, Pune=760}
{false=[Meera, Isha], true=[Aarav, Kabir, Rohan]}
Customers: Aarav, Isha, Kabir, Meera, Rohan.

groupingBy(key, mapFactory, downstream) groups elements by a key and then summarises each group with a downstream collector such as counting() or summingInt(...). Passing TreeMap::new keeps the cities sorted; the simple form groupingBy(Order::city) uses a HashMap with no guaranteed order. partitioningBy splits into exactly two groups, true and false. joining glues strings together with a separator, prefix and suffix. This kind of report, written with loops and maps, would take three times as many lines.

Optional: a value that may be missing

Some operations may have no result: the first student called Priya, the maximum of an empty list. Instead of returning null and risking a NullPointerException, they return an Optional<T>, a small box that is either empty or holds one value. The type itself tells the caller "check before you use".

Java
import java.util.List;
import java.util.Optional;

public class Opt {
    record Student(String name, String city, int marks) {}

    static Optional<Student> findByName(List<Student> list, String name) {
        return list.stream().filter(s -> s.name().equalsIgnoreCase(name)).findFirst();
    }

    public static void main(String[] args) {
        List<Student> students = List.of(
            new Student("Ananya", "Lucknow", 91),
            new Student("Farhan", "Pune", 78));

        Optional<Student> found = findByName(students, "farhan");
        Optional<Student> missing = findByName(students, "Priya");

        System.out.println(found.isPresent() + " " + missing.isPresent());
        System.out.println(found.map(Student::city).orElse("unknown"));
        System.out.println(missing.map(Student::city).orElse("unknown"));

        found.ifPresent(s -> System.out.println("Found " + s.name()));
        missing.ifPresentOrElse(
            s -> System.out.println("Found " + s.name()),
            () -> System.out.println("No student called Priya"));

        Optional<Student> topper = students.stream()
                .max((a, b) -> Integer.compare(a.marks(), b.marks()));
        System.out.println("Topper: " + topper.orElseThrow().name());

        String nickname = null;
        System.out.println(Optional.ofNullable(nickname).orElse("(no nickname)"));

        try {
            missing.orElseThrow();
        } catch (Exception e) {
            System.out.println(e);
        }
    }
}
Output
true false
Pune
unknown
Found Farhan
No student called Priya
Topper: Ananya
(no nickname)
java.util.NoSuchElementException: No value present

The most useful Optional methods:

  • orElse(fallback) returns the value or a default; orElseGet(() -> ...) computes the default only when needed.
  • map(...) transforms the value if there is one, and stays empty otherwise, so you can chain safely.
  • ifPresent(...) and ifPresentOrElse(...) run code depending on whether a value exists.
  • orElseThrow() returns the value or throws NoSuchElementException; use it when a missing value really is a bug.
  • Optional.ofNullable(x) wraps a value that might be null.

Use Optional as a return type for methods that may not find anything. Do not use it for fields or method parameters, and avoid calling get() without checking; that just moves the NullPointerException somewhere else.

Streams or loops?

Streams shine for filter-transform-collect work and summaries like the reports above. Plain loops are still better when you need to break early in complex ways, update several variables at once, or when a loop is simply easier to read. Both are correct Java; choose the clearer one. Avoid changing outside variables or collections from inside a stream's lambdas; return results instead.

Common mistakes

  • Forgetting the terminal operation. list.stream().filter(...).map(...) alone does nothing.
  • Reusing a stream. A stream can be consumed once; call list.stream() again.
  • Expecting toList() to be modifiable. It returns an unmodifiable list; use collect(Collectors.toCollection(ArrayList::new)) if you need to add to it.
  • Calling optional.get() without checking. Use orElse, map, ifPresent or orElseThrow.
  • Using peek or forEach to change outside state. Keep stream lambdas free of side effects.

Key points

  • A stream pipeline is a source, intermediate operations (filter, map, sorted, distinct) and one terminal operation (toList, count, sum, collect).
  • Streams are lazy, single-use and never modify their source.
  • mapToInt gives sum, average, min and max; reduce combines elements.
  • Collectors.groupingBy, counting, summingInt, partitioningBy and joining build reports.
  • Optional represents a value that may be missing; use orElse, map, ifPresent and orElseThrow.
  • Java 24 adds gather with Gatherers such as windowFixed and scan.

Exercise

A cricket coach records runs as records Innings(String player, int runs): Rohan 45, Kabir 12, Rohan 78, Aarav 101, Kabir 33, Aarav 0, Rohan 9. Using streams, print: the total runs per player (sorted by name), the player with the highest single innings, the number of fifties or more, and an Optional search for a player named "Isha" that prints "No innings for Isha" when absent.

Show solution
Java
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.stream.Collectors;

public class Coach {
    record Innings(String player, int runs) {}

    public static void main(String[] args) {
        List<Innings> scores = List.of(
            new Innings("Rohan", 45), new Innings("Kabir", 12), new Innings("Rohan", 78),
            new Innings("Aarav", 101), new Innings("Kabir", 33), new Innings("Aarav", 0),
            new Innings("Rohan", 9));

        Map<String, Integer> totals = scores.stream()
                .collect(Collectors.groupingBy(Innings::player, TreeMap::new,
                         Collectors.summingInt(Innings::runs)));
        System.out.println("Totals: " + totals);

        scores.stream()
              .max(Comparator.comparingInt(Innings::runs))
              .ifPresent(i -> System.out.println("Best innings: " + i.player() + " " + i.runs()));

        long fifties = scores.stream().filter(i -> i.runs() >= 50).count();
        System.out.println("Fifties or more: " + fifties);

        scores.stream()
              .filter(i -> i.player().equals("Isha"))
              .findFirst()
              .ifPresentOrElse(
                  i -> System.out.println("Isha scored " + i.runs()),
                  () -> System.out.println("No innings for Isha"));
    }
}
Output
Totals: {Aarav=101, Kabir=45, Rohan=132}
Best innings: Aarav 101
Fifties or more: 2
No innings for Isha

Comparator.comparingInt builds the comparator for max, which returns an Optional because the list could be empty. findFirst also returns an Optional, handled with ifPresentOrElse.

Quick Quiz

1. What happens if a stream pipeline has no terminal operation?

2. Which operation transforms each element into another value?

3. What does Collectors.groupingBy(Order::city, Collectors.counting()) produce?

4. What should a method return when a search may find nothing?

Learn Java with a mentor

Free lessons get you started. Our Java, C & C++ Programming training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call