← Back to the section

You often need to do the same thing over a collection: pick out the matching elements, extract something from them, add up a total. This used to be a for loop. Lambdas and the Stream API let you describe what you want to get, rather than how to iterate. Let's work through both mechanisms from scratch.

Why you need this: the "what", not the "how"

The task: from a list of users, keep the adults and collect their names. A plain loop:

List<String> names = new ArrayList<>();
for (User u : users) {
    if (u.age() >= 18) {        // select
        names.add(u.name());    // extract the name
    }
}

Two things are tangled together here: the intent (select and extract the name) and the mechanics (create a list, iterate, add). The Stream API lets you write only the intent:

List<String> names = users.stream()
        .filter(u -> u.age() >= 18)   // select
        .map(User::name)              // extract the name
        .toList();

The second version needs two things first: a functional interface and a lambda.

Functional interfaces

A functional interface is an interface with exactly one abstract method. The single method is what lets the compiler know which one we mean when we pass a short function.

The java.util.function package has four base interfaces that come up constantly:

  • Function<T, R> — takes a T, returns an R. Method apply. "A transformation."
  • Predicate<T> — takes a T, returns a boolean. Method test. "A check/condition."
  • Consumer<T> — takes a T, returns nothing. Method accept. "An action with a side effect."
  • Supplier<T> — takes nothing, returns a T. Method get. "A value provider."

live example

import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.function.Supplier;

public class Basics {
    public static void main(String[] args) {
        Function<String, Integer> length = s -> s.length();    // string -> its length
        Predicate<Integer> isPositive = n -> n > 0;            // number -> true/false
        Consumer<String> printer = s -> System.out.println(s); // string -> print
        Supplier<String> greeting = () -> "hello";             // no input -> a value

        System.out.println(length.apply("hello")); // 5
        System.out.println(isPositive.test(-3));   // false
        printer.accept(greeting.get());            // hello
    }
}
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

You can write your own: @FunctionalInterface is optional, but it's a safeguard — the compiler verifies there is just one method.

Lambdas

A lambda is an implementation of a functional interface written right where it's needed, without a separate class. The short formula: (arguments) -> body.

live example

public class Discounts {
    @FunctionalInterface
    interface Discount {
        int applyTo(int price);             // exactly one abstract method
    }

    public static void main(String[] args) {
        Discount half = price -> price / 2; // a single expression — the result is returned automatically
        System.out.println(half.applyTo(100)); // 50
    }
}
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

Several ways to write one:

() -> 42                       // no arguments
x -> x + 1                     // one argument, the parentheses can be omitted
(x, y) -> x + y                // two arguments — parentheses are required
(int x, int y) -> x + y        // you can state the types explicitly
x -> {                         // a multi-line body — needs braces and return
    int doubled = x * 2;
    return doubled + 1;
}

The parameter type is inferred from the functional interface, so it's almost never written out.

An important detail: a lambda may use local variables from the surrounding code, but only if they don't actually change (are effectively final).

live example

import java.util.function.Function;

public class Capture {
    public static void main(String[] args) {
        int bonus = 10;                                  // never assigned again — effectively final
        Function<Integer, Integer> add = x -> x + bonus; // the lambda "captures" bonus
        System.out.println(add.apply(5));                // 15
    }
}
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

Checked exceptions inside a lambda

A lambda has to match the signature of the functional interface method exactly — and Function.apply, Consumer.accept and the other standard interfaces have no throws. So a method with a checked exception inside a lambda doesn't compile:

List<String> texts = paths.stream()
    .map(p -> Files.readString(p))   // error: unreported exception IOException
    .toList();

Two ways around it: wrap it into an unchecked one in the lambda (catch (IOException e) { throw new UncheckedIOException(e); }), or move the reading into a method with the same wrapping and pass .map(this::readQuietly). If errors need different handling per element, or the traversal must stop at the first one, a plain loop with try/catch reads better.

Method references

If a lambda just calls an existing method, a method reference with :: is shorter and behaves the same.

live example

import java.util.ArrayList;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.Supplier;

public class Refs {
    public static void main(String[] args) {
        Function<String, Integer> length = String::length;    // instead of s -> s.length()
        Consumer<String> printer = System.out::println;       // instead of s -> System.out.println(s)
        Supplier<ArrayList<String>> factory = ArrayList::new; // instead of () -> new ArrayList<>()

        System.out.println(length.apply("stream")); // 6
        printer.accept("printed by a method reference");
        System.out.println(factory.get());          // []
    }
}
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

Four kinds of references:

  • String::length — to an instance method by type: the object the method is called on arrives as the first argument.
  • System.out::println — to a method of a specific object.
  • Integer::parseInt — to a static method.
  • ArrayList::new — to a constructor.

The rule: if a lambda only forwards the call into a single method with no extra logic, a method reference is shorter; if there's anything else inside, the lambda stays.

The Stream API: a processing pipeline

A Stream is a pipeline for processing a sequence of elements. It doesn't store data (the source is a collection, an array) and doesn't change it: each step produces a new stream.

A pipeline always has three parts: a source, intermediate operations and a terminal one.

elements travel the pipeline one at a time, not as a list after every step stream() filter n > 10 map n * 2 toList() 12, 5, 20, 3 [] 24 40 1224 2040 5 3 did not pass filter result

The source hands out elements one by one: the one that fails filter goes no further, the one that passes goes straight through map and lands in the result. As long as there is no terminal operation, nothing moves along the pipeline at all.

Intermediate vs terminal operations

This is the key distinction.

  • Intermediate operations (filter, map, sorted, distinct, limit) return a new Stream and do nothing right away — they only record what needs to be done.
  • A terminal operation (collect, toList, count, reduce, findFirst — or forEach, which only has a side effect) runs the whole pipeline and returns a result. After it, the stream can no longer be used.

From this follows laziness: as long as there's no terminal operation, nothing runs. The output order shows it:

live example

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

public class Lazy {
    public static void main(String[] args) {
        List<String> words = List.of("kiwi", "banana", "fig");

        Stream<String> pipeline = words.stream()
                .filter(w -> { System.out.println("checking " + w); return w.length() > 3; })
                .map(String::toUpperCase);
        System.out.println("pipeline described, but not executed");

        List<String> result = pipeline.toList(); // the terminal operation runs it all
        System.out.println(result);
    }
}
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

The "pipeline described" line comes first: not one element was checked before it. Laziness also saves work: elements go through the pipeline one at a time, and limit can stop processing without traversing the whole source.

filter, map

filter keeps the elements that match a Predicate. map transforms each element through a Function.

live example

import java.util.List;

public class FilterMap {
    public static void main(String[] args) {
        List<String> result = List.of("apple", "kiwi", "banana", "fig").stream()
                .filter(s -> s.length() > 3)  // longer than three characters: apple, kiwi, banana
                .map(String::toUpperCase)     // to upper case
                .toList();
        System.out.println(result);           // [APPLE, KIWI, BANANA]
    }
}
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

reduce

reduce collapses a stream into a single value: it takes an initial value and a function that combines the "accumulated" value with the next element. For numbers, specialized streams are more common — shorter and without boxing:

live example

import java.util.List;

public class Sum {
    public static void main(String[] args) {
        int sum = List.of(1, 2, 3, 4).stream()
                .reduce(0, (acc, n) -> acc + n); // 0+1, then +2, +3, +4
        System.out.println(sum);                 // 10

        int fast = List.of(1, 2, 3, 4).stream()
                .mapToInt(Integer::intValue)     // a stream of numbers, no boxing
                .sum();
        System.out.println(fast);                // 10
    }
}
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

collect and Collectors

collect gathers a stream into a collection or another structure. Most often you use ready-made collectors from Collectors: groupingBy buckets elements by a key, joining glues strings. For a simple list, Java 16+ has the short toList().

live example

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

public class Collect {
    record User(String name, int age) {}

    public static void main(String[] args) {
        List<User> users = List.of(new User("Anna", 30), new User("Boris", 17), new User("Vera", 30));

        Map<Integer, List<User>> byAge = users.stream()
                .collect(Collectors.groupingBy(User::age));
        System.out.println(byAge.get(30).size() + " users aged 30");

        String joined = users.stream()
                .map(User::name)
                .collect(Collectors.joining(", "));
        System.out.println(joined);  // Anna, Boris, Vera
    }
}
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

When to use a stream, and when a plain loop

The Stream API does not replace the loop everywhere. The guideline is simple.

A stream fits when there's a chain of transformations over a collection (select → transform → collect/count) — it reads like a description of intent.

A plain loop is better when:

  • you need a side effect at every step (writing to a file or a database) — a loop is more honest for that than forEach;
  • the logic is complex: early exit, several state variables, nested conditions;
  • step-by-step debugging matters or you need the element's index;
  • it's a hot path, where boxing/unboxing numbers in a stream adds overhead.

The short formula: a stream is for transforming data, a loop is for controlling the flow of execution. And the source is not modified inside a stream — that breaks the stream's model and leads to hard-to-catch bugs.

In short

  • A functional interface is an interface with one abstract method; the base ones: Function, Predicate, Consumer, Supplier.
  • A lambda (args) -> body is an implementation of such an interface right on the spot; captured local variables must be effectively final.
  • A method reference (String::length, System.out::println, ArrayList::new) is the short form of a lambda when it merely calls an existing method.
  • A Stream is a pipeline: source → intermediate operations (filter, map) → terminal one (collect, count, reduce); Collectors (groupingBy, joining) shape what collect builds, and a simple list is just toList().
  • Intermediate operations are lazy: nothing runs until a terminal operation is called, and elements travel the pipeline one at a time.
  • A stream is for transforming data; a plain loop is for side effects, complex control flow and hot paths.