Definition
peek is a debugging aid whose execution the stream contract does not guarantee.
- Intermediate and advisory – peek is an intermediate operation whose execution for every element is not guaranteed.
- May be skipped since Java 9 – when the stream is SIZED and the terminal does not need element values, as with
count(), the pipeline may skip peek entirely. - Silent failure – real side effects in peek, such as saving to a database or incrementing a counter, may simply not happen depending on the terminal and JDK version.
- Correct homes – transforming side effects belong in
map; terminal side effects belong inforEachor a custom collector, both guaranteed to run. - A documented optimization – this is behaviour by design, not a bug, which is why it is a trap for business logic.
Priya watches account forms on a belt at the National Bank of India and jots notes. But if the bank only wants a head count, the belt operator may just read the counter and never let the forms pass her desk at all, so her notes stay empty. You cannot trust note-taking to actually record every form; you use a proper desk whose job is recording.
Understanding the Concepts
What does the Stream contract say about peek?
The Stream.peek Javadoc states that the operation exists mainly to support debugging, where you want to see elements as they flow past a point in the pipeline. It is deliberately not described as a place to perform meaningful work. That wording is the first warning: peek is an observation tool, and the contract does not promise it runs for every element.
package com.bank.perf;
import java.util.List;
import java.util.stream.Collectors;
public class PeekIsForDebug {
public static void main(String[] args) {
// Intended use: watch elements during development
List<String> result = List.of("ACC-1", "ACC-2").stream()
.peek(a -> System.out.println("debug: saw " + a))
.collect(Collectors.toList());
System.out.println(result);
}
}
debug: saw ACC-1
debug: saw ACC-2
[ACC-1, ACC-2]
Why can the pipeline skip peek since Java 9?
The Java 9 release made this concrete: for a SIZED stream, count() can return the size without traversing elements, so any upstream peek is never invoked. If the runtime already knows how many elements there are, it does not need to walk them, and therefore does not run the peek stage.
package com.bank.perf;
import java.util.List;
public class CountSkipsPeek {
public static void main(String[] args) {
long n = List.of("a", "b").stream()
.peek(System.out::println) // Java 9+: never invoked
.count();
System.out.println("count = " + n);
}
}
count = 2
Nothing from the peek printed, because the size was known to be two without visiting the elements.
What breaks when you put a real side effect in peek?
If you write a real side effect in peek, such as saving a record, incrementing a counter, or sending a notification, that effect may silently not happen. The code looks correct and may even work on an older JDK or a different terminal, then quietly stops working after an upgrade.
package com.bank.perf;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
public class SideEffectLost {
public static void main(String[] args) {
AtomicInteger audited = new AtomicInteger();
// WRONG: audit increment lives in peek, then a count terminal follows
long total = List.of("ACC-1", "ACC-2", "ACC-3").stream()
.peek(a -> audited.incrementAndGet()) // may be skipped
.count();
System.out.println("count = " + total); // 3
System.out.println("audited = " + audited.get()); // 0 on Java 9+
}
}
count = 3
audited = 0
The count is correct, but the audit counter is zero because peek never ran.
How do you fix it by moving the effect to map?
When the side effect accompanies a transformation, put it in map, which is guaranteed to run for every element that reaches it. map returns a value, so the pipeline cannot skip it the way it skips a value-less peek before count.
package com.bank.perf;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.stream.Collectors;
public class FixWithMap {
public static void main(String[] args) {
AtomicInteger audited = new AtomicInteger();
// CORRECT: the audit effect is part of a map transformation
List<String> ids = List.of("ACC-1", "ACC-2", "ACC-3").stream()
.map(a -> { audited.incrementAndGet(); return a; })
.collect(Collectors.toList());
System.out.println("ids = " + ids);
System.out.println("audited = " + audited.get()); // 3
}
}
ids = [ACC-1, ACC-2, ACC-3]
audited = 3
How do you fix it by moving the effect to forEach?
When the effect is terminal, such as persisting each record, use forEach, which the contract guarantees to run for every element. forEach ends the pipeline, so there is no later terminal that could optimize it away.
package com.bank.perf;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
public class FixWithForEach {
public static void main(String[] args) {
AtomicInteger saved = new AtomicInteger();
// CORRECT: terminal side effect runs for every element
List.of("ACC-1", "ACC-2", "ACC-3").forEach(a -> saved.incrementAndGet());
System.out.println("saved = " + saved.get()); // 3
}
}
saved = 3
How do peek, map, and forEach compare for side effects?
The difference is the guarantee. Only map, forEach, and collectors are contractually run for every element that reaches them.
| Operation | Type | Runs for every reached element? | Right for side effects? |
|-----------|------|---------------------------------|-------------------------|
| peek(Consumer) | intermediate | not guaranteed (may be skipped) | no, debugging only |
| map(Function) | intermediate | yes | yes, transforming effects |
| forEach(Consumer) | terminal | yes | yes, terminal effects |
| collectors | terminal | yes | yes, accumulation effects |
// debugging only, deletable with no loss of correctness
stream.peek(e -> log.fine(() -> "saw " + e)).collect(toList());
// transforming effect that must run
stream.map(e -> { persist(e); return e; }).collect(toList());
// terminal effect that must run
stream.forEach(this::persist);
What is the edge case difference between count and forEach terminals?
The same peek behaves differently depending on the terminal. With a value-needing terminal such as forEach or collect, peek runs; with a size-only terminal such as count on a sized stream, it may be skipped. This terminal-dependence is what makes peek unreliable.
package com.bank.perf;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
public class TerminalDependence {
public static void main(String[] args) {
AtomicInteger c1 = new AtomicInteger();
List.of("a", "b").stream().peek(x -> c1.incrementAndGet()).count();
System.out.println("after count = " + c1.get()); // 0 on Java 9+
AtomicInteger c2 = new AtomicInteger();
List.of("a", "b").stream().peek(x -> c2.incrementAndGet()).forEach(x -> {});
System.out.println("after forEach = " + c2.get()); // 2
}
}
after count = 0
after forEach = 2
Why is this an optimization and not a bug?
Skipping traversal when the result can be computed structurally is a legitimate performance optimization. Counting a sized stream in constant time instead of walking millions of elements is a real win. The danger is only that developers misuse peek for essential logic, so the safe mental model is that peek may run zero or more times per element and you must never depend on it.
// This is fast and correct: no need to walk elements to count them
long size = someSizedStream.count(); // O(1) when size is known
// The optimization is only harmful if you hid a side effect upstream in peek
How should a reviewer treat peek in a pull request?
A reviewer should flag any peek that mutates state or performs input-output as a correctness risk, not merely a style issue. Logging that is purely advisory and safe to delete is acceptable; anything the program depends on must move to a guaranteed stage.
// FLAG in review: side effect the program depends on, hidden in peek
stream.peek(e -> auditRepo.save(e)).count(); // audit may never run
// ACCEPT in review: advisory debug log, deletable with no behaviour change
stream.peek(e -> log.fine(() -> "processing " + e)).collect(toList());
What are common interview follow-up questions?
Expect: Why should peek not carry side effects? Answer: the pipeline may legally skip it, so effects can silently not run. When specifically is peek skipped? Answer: since Java 9, on a SIZED stream with a size-only terminal such as count. Where should the side effect go instead? Answer: map for transforming effects, forEach or a collector for terminal effects, all guaranteed to run. Is the skipping a bug? Answer: no, it is a documented optimization, which is exactly why relying on peek is dangerous. How do you catch this in review? Answer: treat any peek that mutates state or does input-output as a correctness risk and require it to move to a guaranteed stage.
Visual Overview
WHY peek IS UNSAFE FOR SIDE EFFECTS
stream.peek(save).count() (SIZED stream, Java 9+)
│
▼
┌────────────────────┐
│ size known already │ ──▶ returns size
│ elements NOT walked│ peek(save) NEVER runs ──▶ audit LOST
└────────────────────┘
TERMINAL DECIDES WHETHER peek RUNS
peek(effect) + count() ──▶ may be SKIPPED (effect runs 0x)
peek(effect) + forEach() ──▶ runs (effect runs Nx)
peek(effect) + collect() ──▶ runs (effect runs Nx)
MOVE THE EFFECT TO A GUARANTEED STAGE
WRONG CORRECT
┌──────────────────┐ ┌──────────────────────────┐
│ .peek(save) │ ─────▶ │ .map(e -> { save(e); │ transforming effect
│ .count() │ │ return e; }) │
└──────────────────┘ └──────────────────────────┘
┌──────────────────────────┐
│ .forEach(e -> save(e)) │ terminal effect
└──────────────────────────┘
Key Takeaway
peek is documented as an intermediate operation that exists mainly to support debugging, and crucially the pipeline is allowed to skip it. Since Java 9, if the stream is sized and the terminal operation is count, the runtime can return the size without ever traversing the elements, which means any peek upstream is never invoked. So if you place a real side effect in peek, such as writing an audit record, incrementing a counter, or sending a notification, that effect can silently fail to happen depending on the terminal operation and the JDK version.
Rajesh learned this the hard way on a banking project at the National Bank of India where an audit write sat inside peek and, after a Java upgrade, audit entries went missing because a count-based terminal skipped the peek stage entirely. The fix was to move the side effect into a guaranteed stage: map for a transformation that also records, or forEach and custom collectors for terminal effects, all of which are contractually run for every element that reaches them.
The point worth stressing is that this is not a bug but a documented optimization, which is exactly why it is dangerous. The code looks correct and often works, until a terminal operation or JDK version changes the traversal and the hidden effect quietly disappears. Computing a count in constant time instead of walking millions of elements is a genuine performance win; the only harm is depending on peek for logic.
So in review, Pankaj flags any peek that mutates state or performs input-output as a correctness risk, not just a style issue, and insists that side effects live only in stages the contract promises to execute. peek stays reserved for advisory debug logging that could be deleted with no change in behaviour.
Remember one sentence: the pipeline may legally skip peek, so never put anything in it that the program cannot afford to lose.
---