skip to content

Why are side effects in stream operations (and especially using peek for mutation) discouraged, and what is peek actually for?

level: middleimportance: should knowfreq 50%

answer

  1. peek = look/log, never mutate
  2. Intermediate ops are lazy — no terminal op, no execution
  3. Short-circuiting/JIT can run or elide peek unpredictably
  4. Parallel + shared mutable state = data race
  5. Accumulate with collectors, not external side effects

basics

~20 s

Streams expect each step to just transform data, not change outside state. peek is meant for debugging/logging only. Mutating in peek or map is fragile because steps are lazy and may be skipped, reordered, or run in parallel.

solid answer

~50 s

Stream operations are designed to be side-effect-free: each lambda should compute from its input and return a value, not mutate external state. The reasons are practical. First, intermediate operations are lazy — they only run when a terminal op pulls elements, so a peek or map whose side effect you rely on may never execute, or may execute for fewer elements than you expect because of short-circuiting (findFirst, limit, anyMatch). Second, parallel streams run lambdas on multiple threads, so mutating a shared variable or collection is a data race. peek specifically exists for observing the pipeline — debugging and logging — and its documentation explicitly says behavior is unspecified if you use it to alter state; the JIT may even elide peek entirely when its result is provably unused. If you need to accumulate results, use a collector (toList, groupingBy, reduce) or forEach at the terminal end — not a side effect tucked inside an intermediate step.

go deeper

for a junior

Knows peek is for debugging/logging and that mutating inside stream steps is discouraged.

for a middle

Explains laziness, short-circuiting, and parallel races as concrete reasons, and uses collectors/forEach appropriately.

for a senior

Reasons about JIT elision of peek, ordering guarantees of forEach vs forEachOrdered, and designs pipelines that are pure end-to-end.

for a principal

Sets review standards banning side-effecting intermediate ops, and guides safe accumulation patterns (collectors, concurrent collectors) for parallel workloads.

## Definitions - **Side effect**: a lambda doing something *observable outside itself* — mutating a field, adding to an external list, incrementing a counter, writing to I/O — rather than just returning a value computed from its input. - **Pure function**: a function whose output depends only on its input and which changes nothing external. Streams are built assuming the functions you pass are (mostly) pure. - **`peek`**: an **intermediate** operation that takes a `Consumer`, runs it on each element as it flows by, and passes the element through **unchanged**. It returns a stream of the same elements. - **Laziness**: intermediate operations don't run when you call them; they run only as the **terminal** operation pulls elements through. - **Short-circuiting**: terminal ops like `findFirst`, `anyMatch`, `limit(n)` stop early, so upstream lambdas run **fewer** times than the source size. ## Why side effects inside the pipeline are a trap ### 1. Laziness means it might not run An intermediate operation with no terminal op never executes: ```java list.stream().peek(x -> log.info("{}", x)); // logs NOTHING — no terminal op ``` ### 2. Short-circuiting means it runs a surprising number of times ```java list.stream() .peek(x -> counter.incrementAndGet()) // how many times? .anyMatch(x -> x > 10); // stops at the first match ``` The counter reflects only elements processed *up to* the first match, not the whole list. Worse, the JIT is allowed to **elide** `peek` when it can prove the elements' identity is all that matters and the side effect is unobservable to the stream — so the count can be even less than you'd guess. ### 3. Parallel streams race ```java List<String> sink = new ArrayList<>(); big.parallelStream().forEach(sink::add); // DATA RACE: ArrayList isn't thread-safe ``` Multiple threads mutate one non-thread-safe structure → corrupted state, lost elements, or `ArrayIndexOutOfBoundsException`. Even an `int` captured and mutated would be a race (and won't compile if you try, because captured locals must be effectively final — which is itself a hint not to do this). ## What `peek` is actually for `peek` is a **debugging/observability** hook: log or inspect elements as they pass, without changing them. Its Javadoc explicitly says that for pipelines where the side effect is the *point*, behavior is unspecified, and the operation may be optimized away. So: ```java result = orders.stream() .filter(Order::isPaid) .peek(o -> log.debug("kept {}", o.id())) // OK: logging only .map(Order::total) .collect(toList()); ``` That is fine. Using `peek` to *mutate* each order, or to populate an external list, is the misuse. ## The correct way to produce results Let the **terminal** operation own the accumulation, via a **collector** (which is built to be safe even in parallel) or a controlled `forEach`: ```java List<Integer> totals = orders.stream() .filter(Order::isPaid) .map(Order::total) .collect(Collectors.toList()); // no external mutation Map<String, List<Order>> byCustomer = orders.stream() .collect(Collectors.groupingBy(Order::customerId)); ``` If you must do a side effect (I/O, say), use `forEach` (sequential) or `forEachOrdered`, and never mutate shared, non-thread-safe state from a parallel stream — use a collector or a concurrent/atomic accumulator instead. ## One-line takeaway Keep intermediate steps pure; use `peek` only to *look*, not to *change*; produce results with a collector at the terminal end.

  • Is forEach also discouraged for side effects?
    forEach is a terminal op meant for side effects, so it's legitimate — but for parallel streams it gives no ordering guarantee (use forEachOrdered) and you still must avoid mutating shared non-thread-safe state. Prefer a collector when you're building a result.
  • Why might a peek statement seem to 'not run' at all?
    Because it's an intermediate (lazy) operation: with no terminal operation, nothing pulls elements through, so the Consumer never fires. The JIT may also elide peek when its effect is unobservable to the pipeline.

saying these in an interview costs you the question

  • Using peek to transform elements (that's map's job) or to fill a list
  • Relying on a side effect in a lazy intermediate op to always run
  • Mutating a shared ArrayList from a parallel forEach
  • Assuming peek runs exactly once per source element regardless of short-circuiting

context