Course contents

peek, forEach and side effects

Debugging with `peek`, terminal `forEach`, `forEachOrdered`, side effects, non-interference, stateless lambdas and collecting safely.

PremiumOften askedLesson13 min readCoreReviewed Oct 2026 · Java 21
On this page
  1. Picture it first: an inspection window and an exit bin
  2. peek under the hood: useful, but not load-bearing
  3. forEach is a terminal operation, not a better loop
  4. Ordering: forEach, forEachOrdered and Iterable.forEach
  5. Side effects: non-interference and stateless lambdas
  6. How to say it in the interview

🔍 What are

peek, forEach and side effects? peek is an intermediate stream operation that lets you look at elements as they pass through a pipeline. forEach is a terminal operation that consumes elements and produces no stream result. A side effect is any action that changes something outside the pipeline, like adding to a list, updating a counter, logging, or mutating the source. Think of peek as a temporary inspection window on a conveyor belt, and forEach as the worker at the end who takes each item off the belt.

For this lesson, we'll focus on the stream-operation angle: when peek runs, why forEach is not a loop with a break, how ordering changes in parallel streams, and why mutating outside state from a pipeline is usually the bug. We covered laziness, flags, and the count() surprise in How a stream pipeline executes, so here we'll use that model instead of rebuilding it.

Below the line (out of scope)

The 30-second answer

peek is for debugging and observation, not for required logic. It is lazy, so it runs only when a terminal operation traverses the element, and it may not run at all when count() can use a known size or when a short-circuiting operation stops early. forEach is a terminal operation for consuming elements; it has no break, so use limit, takeWhile, findFirst, anyMatch, or an ordinary loop when stopping is part of the algorithm. In parallel streams, forEach can run in any order and on multiple threads, while forEachOrdered preserves encounter order at a coordination cost. Lambdas in stream pipelines should be non-interfering and usually stateless: do not modify the source while traversing it, and do not fill an external ArrayList from a parallel stream. Return a value with collect, toList, reduce, or a proper collector instead.

What you’ll learn

  • Explain why **peek() is for debugging**, not business logic
  • Predict when peek does not run because of count() on a SIZED source or short-circuiting
  • Compare **forEach and forEachOrdered** in sequential and parallel streams
  • Separate Iterable.forEach from stream().forEach and choose the clearer option
  • Show why mutating external state from a stream, especially an ArrayList in parallel, is a bug
  • Apply the non-interference and stateless-lambda rules, then replace side effects with collect

01Picture it first: an inspection window and an exit bin

Picture a warehouse conveyor. A small glass window lets you inspect packages while they pass, but it does not pull packages by itself. The exit bin at the end is what makes the belt move.

That is the relationship between peek and forEach. peek observes traversal; forEach starts traversal. If the terminal operation does not need to open the belt, or it stops after three packages, your inspection window sees only that much.

Fig. 1

A stream conveyor shows peek as an inspection window that only sees traversed elements and forEach as the terminal exit bin that consumes them

A stream conveyor shows peek as an inspection window that only sees traversed elements and forEach as the terminal exit bin that consumes them

Notice the control point. The terminal operation decides how much source data is requested. peek has no promise that every source element will pass its window.

02peek under the hood: useful, but not load-bearing

The JDK describes peek as mainly existing to support debugging. I treat that wording seriously in code review. If removing a peek changes the business result, the pipeline is carrying logic in the wrong place.

Try it yourself first

Before reading the output, predict whether peek prints anything. Then ask yourself why adding filter(s -> true) changes the behavior.

import java.util.List;

final class PeekCountDemo {
    public static void main(String[] args) {
        long count = List.of("new", "paid", "shipped").stream()
                .peek(status -> System.out.println("peek " + status))
                .count();

        System.out.println("count " + count);
    }
}

On Java 21, the output is:

count 3

No peek lines print. A List stream is SIZED, and peek does not change the number of elements, so count() can answer from metadata. Since Java 9, this optimization is visible to you when peek is used for logging or mutation.

Short-circuiting is the other common surprise.

Try it yourself first

Walk this pipeline one element at a time. Which statuses does peek print before anyMatch has enough information?

import java.util.List;

final class PeekShortCircuitDemo {
    public static void main(String[] args) {
        boolean hasPaid = List.of("new", "retry", "paid", "shipped").stream()
                .peek(status -> System.out.println("seen " + status))
                .anyMatch("paid"::equals);

        System.out.println("has paid " + hasPaid);
    }
}

The output is:

seen new
seen retry
seen paid
has paid true

shipped is not visited because anyMatch already knows the answer. This is not a bug in peek. It is the stream model doing exactly what pipeline execution says it can do.

Notice three things:

  • peek is intermediate and lazy. It needs a terminal operation.
  • peek observes elements that are actually traversed, not necessarily every element in the source.
  • If the action is required, put it in a terminal operation or, better, produce data and handle the effect outside the stream.
In the real world

I once saw a billing job add audit records in peek and finish with count() because the result only needed a number. It looked fine in a quick manual test after a filter was present. A cleanup removed the filter, count() used the list size, and the audit table went quiet. The fix was not another peek; it was separating counting from audit-record creation.

Premium lesson

Keep reading with the Java Pass

The rest of this lesson is part of Java One-Stop Interview Prep: in-depth explanations, diagrams, model answers and how to say them in the interview.

  • forEach is a terminal operation, not a better loop
  • Ordering: forEach, forEachOrdered and Iterable.forEach
  • Side effects: non-interference and stateless lambdas
  • How to say it in the interview

From ₹199 · one-time payment · No auto-renewal

Practise this topicOperations with streams: interview questions & scenarios
Further reading (5)