Java

Streams API Part 1: map, filter, and forEach

Executive Summary

In the Streams API, a stream is a one-use pipeline over a source. That source can be a collection’s stream(), an array through Arrays.stream, fixed values via Stream.of, or numbers via IntStream.range. Intermediate operations, filter with a Predicate, map with a Function, plus distinct, sorted, and limit, return new streams and do no work. By contrast, terminal operations, forEach, count, anyMatch, and findFirst among them, run the whole pipeline in one pass. Laziness means nothing executes until a terminal operation arrives, so a pipeline without an ending does nothing at all. Streams never modify their source and cannot be reused. In fact, a second terminal call throws IllegalStateException. The pipeline style replaces braided loops with composable, typed steps. Then the next article completes the story with reduce and collect, turning pipelines into lists, maps, and values.

From Loops to Pipelines

The clearest way to see what the Streams API buys you is the translation itself. Wrong code first, the imperative shape that has carried Java for decades:

// WRONG: three jobs braided into one loop
for (Book book : books) {
    if (book.availableCopies() > 0) {                 // job 1: filtering
        String title = book.title().toUpperCase();    // job 2: transforming
        System.out.println("available: " + title);   // job 3: consuming
    }
}
// RIGHT: the three jobs, as three composable steps
books.stream()
     .filter(book -> book.availableCopies() > 0)      // keep some
     .map(Book::title)                               // transform each
     .map(String::toUpperCase)                         // transform again
     .forEach(title -> System.out.println("available: " + title));   // use each

The delta: the loop braids filtering, transformation, and output into one mutable pass, and every new requirement deepens the braid. The pipeline states each concern as a step, in order, composable with the steps before and after it. Nothing here is faster than the loop; it is more correct, more readable, and, as the pipeline grows, dramatically shorter.

Sources: Where Streams Begin

Every Streams API pipeline starts at a source, and the JDK gives one to almost every data shape you own, per the stream package summary:

Source Code Element type
A collection names.stream() The collection’s type
Fixed values Stream.of(“a”, “b”) The values’ type
An array Arrays.stream(scores) The array’s type
A number range IntStream.range(1, 6) int, unboxed
Characters of text “java”.chars() int code points
An empty stream Stream.empty() Whatever the context needs

Collection sources come straight from the collections articles, and array sources continue what the arrays article taught: streams view the data; the array underneath still holds it. IntStream deserves one sentence now. It streams primitives directly, avoiding the boxing cost the collections article measured, and its arithmetic shortcuts, sum and average, appear in the next article.

The Core Operations: filter, map, forEach

Three operations carry most pipelines, and each takes a functional interface by now familiar from the lambdas article:

// filter: Predicate<T> decides who stays
books.stream().filter(book -> book.availableCopies() > 0);

// map: Function<T, R> transforms each element into something else
books.stream().map(Book::title);              // Book -> String
books.stream().mapToInt(Book::totalCopies);   // Book -> int, unboxed

// forEach: Consumer<T> runs for each surviving element
titles.forEach(System.out::println);

map is the workhorse of the family, and its type transformation is the pipeline’s type system at work. For example, filter a Stream<Book> and you still have a Stream<Book>. However, map it with Book::title and the compiler now proves you hold a Stream<String>, exactly the generics article‘s guarantees flowing through the pipeline. A complete, runnable example using all three:

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

public class PipelineDemo {
    record Book(String title, int availableCopies) { }

    public static void main(String[] args) {
        var books = List.of(
                new Book("Effective Java", 2),
                new Book("Clean Code", 0),
                new Book("Java Concurrency in Practice", 1));

        books.stream()
             .filter(book -> book.availableCopies() > 0)
             .map(Book::title)
             .map(String::toUpperCase)
             .forEach(title -> System.out.println("available: " + title));

        // primitives, unboxed from start to finish
        IntStream.range(1, 6)
                 .filter(n -> n % 2 == 1)
                 .map(n -> n * n)
                 .forEach(System.out::println);   // 1, 9, 25
    }
}
available: EFFECTIVE JAVA
available: JAVA CONCURRENCY IN PRACTICE
1
9
25

Laziness: Nothing Runs Until the Terminal

Streams are lazy: intermediate operations describe work, and nothing happens until a terminal operation pulls elements through. The aggregate operations lesson demonstrates this, and one program proves it:

var pipeline = List.of("a", "b", "c").stream()
        .peek(s -> System.out.println("peeked: " + s));   // instrumentation

System.out.println("pipeline built");     // prints FIRST: no element touched yet
pipeline.forEach(s -> { });              // only now do the peeked lines appear

// output:
// pipeline built
// peeked: a
// peeked: b
// peeked: c

The two operation kinds follow directly from this:

Kind Examples Behavior
Intermediate filter, map, peek, distinct, sorted, limit, takeWhile Lazy: returns a new Stream, does no work
Terminal forEach, count, anyMatch, allMatch, findFirst, findAny, toArray Runs the pipeline, produces a result or effect

Two practical consequences follow. A pipeline without a terminal operation is a statement that does nothing, a surprisingly common bug in first-month stream code, and the compiler will not warn you. Laziness also enables short-circuiting. anyMatch stops at the first hit, findFirst stops at the first element that survives, and limit stops pulling early. As a result, an infinite source paired with limit is legal and useful.

peek Is for Watching, Not Working

peek exists for debugging, to watch elements flow past a point in the pipeline. It is tempting to use it for processing, but that is a mistake. It is documented for observation, and its behavior around skipped elements makes any real work unreliable. Watch with peek; work with map and forEach.

One Stream, One Use

Streams are pipelines, not containers, and a pipeline fires once. Wrong code first:

// WRONG: a stream is not a reusable collection
var stream = List.of(1, 2, 3).stream();
stream.forEach(System.out::println);      // 1 2 3
stream.forEach(System.out::println);      // IllegalStateException:
                                          // stream has already been operated upon
// RIGHT: a fresh pipeline whenever you need another pass
List.of(1, 2, 3).stream().forEach(System.out::println);
List.of(1, 2, 3).stream().forEach(System.out::println);

The delta: the source List is untouched and re-streamable forever, while the stream is a one-shot traversal over it. Streams also never modify their source: books.stream() after a filter-and-map leaves the books list exactly as it was, which is what makes pipelines safe to compose freely. Keep the rule in the vocabulary of the collections article: the collection is the noun, while the stream is a sentence about it.

How Real Systems Do This

The Streams API is the default data-processing syntax in modern production Java: request validation chains, event enrichment, filtering dashboards, report generation. The shape is always the same: source, zero or more intermediate steps, terminal effect or value. The next article’s collect turns those pipelines into the lists and maps services return. This article’s forEach, by contrast, is the streaming effect, logging, sending, persisting one element at a time.

In my experience, the honest sales pitch is correctness, not speed. A reporting loop I replaced in a checkout service was sixty lines braiding four concerns and had two bugs, both of them bookkeeping. One was a counter incremented on the wrong branch, and the other was a duplicate suppression check placed after the transformation instead of before. The six-line pipeline replaced both bugs with structure, because the transformation order is stated, not remembered. In short, streams made the code provable, readable, and one tenth the size, at identical runtime cost.

The performance caveats are real and covered later. Part 3’s parallel article covers when parallelStream is a win, and the profiling article covers why tight primitive loops sometimes beat boxed streams. For ordinary data sizes and ordinary services, however, the readability and correctness are free.

Decision Framework

  1. Is the job a transformation or filtering of a sequence? If so, use a pipeline, ending in forEach for effects or, from the next article, collect for values.
  2. Is the job a single clear mutation of existing state? A loop or a named method; streams add ceremony when there is nothing to compose.
  3. Does every element need processing, or can you stop early? anyMatch, findFirst, and limit short-circuit, and a loop rarely says that as clearly.
  4. Are the elements primitives over large volumes? IntStream and its siblings avoid boxing; beyond that, measure before choosing streams for hot paths.
  5. Does the body throw checked exceptions? If so, wrap into a domain exception inside the lambda, per the custom exceptions article, because streams have no throws channel.
  6. Are you writing a forEach that builds a list? Stop: that is collect, the next article’s subject, stated in one call.

When NOT to Use This

  • Do not use forEach as a loop with extra steps. If the body mutates captured collections or accumulates counters, the code is procedural work wearing stream syntax. Instead, use map and collect, or an honest loop.
  • Do not nest pipelines inside pipelines. Flattening belongs to flatMap, which the next article introduces. Nested streams, by contrast, read as a puzzle and usually hide a join.
  • Do not stream tiny, fixed sequences where the intent is positional. Three elements processed by index is a loop, and every reader knows what it means at a glance.

Common Mistakes

  • Building a pipeline with no terminal operation: nothing runs, nothing warns, and the code reviews as if it worked. Every pipeline ends in a terminal call.
  • Reusing a stream. The second terminal call throws IllegalStateException, so the fix is a new pipeline over the same source, which streams freely forever.
  • Expecting intermediate operations to execute eagerly. filter and map describe work; only the terminal pull performs it, which is why debug prints inside map do not appear until the end.
  • Mutating the source collection during the pipeline: ConcurrentModificationException, the same contract the collections article taught, wearing stream clothes.
  • Doing real work inside peek. It is observation-only by contract, and behavior around skipped elements makes it unreliable as a processing step.
  • Sorting or distincting a huge stream when a small upstream limit would do. Short-circuiting operations before expensive ones is free performance, because laziness composes.

Key Takeaways

  • A stream is a one-use, lazy pipeline over a source: collections, arrays, fixed values, or primitive ranges.
  • filter takes a Predicate, map takes a Function, forEach takes a Consumer: the three functional shapes from the lambdas article, composed in order.
  • Intermediate operations return streams and do no work, while terminal operations run the pipeline. No terminal call, no execution.
  • Laziness enables short-circuiting: anyMatch, findFirst, and limit stop early, and expensive steps belong after cheap ones.
  • Streams never modify their source and cannot be reused; a second terminal call throws IllegalStateException.
  • peek is for debugging only; real work goes in map and forEach.
  • Streams win on correctness and readability, not raw speed; the parallel article covers the performance frontier.

FAQ

What are Java streams?

A pipeline over a sequence: a source (a collection, an array, a range) flows through lazy intermediate operations like filter and map and ends at one terminal operation. In other words, streams describe what to do with the data, not how to iterate it.

What is the difference between map and forEach in Java?

map is an intermediate operation that transforms each element into another value, keeping the pipeline alive; forEach is a terminal operation that consumes each surviving element for an effect, like printing, and ends the pipeline.

Are Java streams lazy?

Yes. Intermediate operations record what should happen, and nothing executes until a terminal operation pulls elements through. This also enables short-circuiting and one-pass fusion of all steps.

Can a Java stream be used twice?

No. A stream is a one-shot pipeline: the second terminal operation throws IllegalStateException. The source collection is untouched, so creating a fresh stream over it is free.

What is the difference between intermediate and terminal operations?

Intermediate operations, filter, map, sorted, limit, return a new Stream and perform no work. In contrast, terminal operations, forEach, count, anyMatch, produce a result or an effect and execute the whole pipeline in one pass.

Conclusion

You can now state data processing as pipelines: source, filter, map, effect. Meanwhile, the compiler checks the type at every arrow, and laziness fuses everything into one pass. This is half the Streams API; the missing half is endings that produce values.

The next article completes the toolset: reduce for folding a stream into one value, collect with Collectors for building lists, maps, and strings. It also covers groupingBy, the single most productive method in the API.

Describe the data, not the loop. Laziness makes the description cheap; the compiler makes it honest.

Last updated on 22 September 2026.

Share this article

Leave a Reply

Your email address will not be published. Required fields are marked *