Definition
peek is the stream equivalent of adding a print statement inside a loop.
- peek(Consumer) – takes a consumer, runs it on each element as the element passes that stage, then hands the same element downstream unchanged.
- Intermediate and lazy – nothing runs until a terminal operation such as
collectorforEachpulls elements, sostream().peek(...)alone does nothing. - Diagnostic placement – put one peek before a filter and one after it to see which records survived and which were dropped.
- Observation only – the consumer should not mutate elements or external state; peek is for watching, not working.
- Temporary – you add peek while investigating a wrong result and remove it before the code ships.
Imagine a conveyor belt of account forms at the National Bank of India. Priya stands beside the belt with a notebook and writes down each form's name as it passes, but she never changes a form; it keeps moving to the next desk. That note-taking is exactly what peek does. If Rajesh wants to know why a report dropped some accounts, he puts Priya before and after the sorting desk and compares her two notebooks.
Understanding the Concepts
What is peek and what does it print?
peek takes a Consumer and runs it on each element as that element passes through the peek stage, then returns the same element to the next stage. Its signature is Stream. The consumer usually logs or prints, so you can see each element the pipeline processes.
package com.bank.debug;
import java.math.BigDecimal;
import java.util.List;
import java.util.stream.Collectors;
public class PeekPrint {
record Employee(String name, BigDecimal salary) {}
public static void main(String[] args) {
List<Employee> staff = List.of(
new Employee("Rajesh", new BigDecimal("60000")),
new Employee("Priya", new BigDecimal("45000"))
);
List<Employee> result = staff.stream()
.peek(e -> System.out.println("processing: " + e.name()))
.collect(Collectors.toList());
System.out.println("collected " + result.size() + " employees");
}
}
processing: Rajesh
processing: Priya
collected 2 employees
Why use peek for debugging instead of breaking the pipeline?
Without peek you would have to split the pipeline, collect to an intermediate list, print it, and rebuild the rest. peek lets you insert a single observation line between any two stages and remove it later, keeping the pipeline fluent while you investigate.
// Before: break the pipeline to inspect, then rebuild
List<Employee> survivors = staff.stream()
.filter(e -> e.salary().compareTo(new BigDecimal("50000")) > 0)
.collect(Collectors.toList());
System.out.println(survivors); // manual inspection step
List<String> names = survivors.stream().map(Employee::name).collect(Collectors.toList());
// After: inspect in place with peek, in one pipeline
List<String> namesWithPeek = staff.stream()
.filter(e -> e.salary().compareTo(new BigDecimal("50000")) > 0)
.peek(e -> System.out.println("survivor: " + e.name()))
.map(Employee::name)
.collect(Collectors.toList());
How does the lazy nature of peek affect when it runs?
peek is intermediate, so it is lazy. The consumer runs only when a terminal operation pulls elements. A pipeline that ends at peek with no terminal does nothing at all.
package com.bank.debug;
import java.util.List;
public class PeekLazy {
public static void main(String[] args) {
// No terminal: peek never runs
List.of("Rajesh", "Priya").stream()
.peek(n -> System.out.println("peeked: " + n));
System.out.println("nothing was peeked above");
// Add a terminal: now peek runs
long count = List.of("Rajesh", "Priya").stream()
.peek(n -> System.out.println("peeked: " + n))
.filter(n -> n.length() > 4)
.count();
System.out.println("count = " + count);
}
}
nothing was peeked above
peeked: Rajesh
peeked: Priya
count = 2
How do you place peek before and after a filter to diagnose a wrong count?
This is the core debugging pattern. A peek before the filter logs every incoming employee; a peek after the filter logs only the survivors. Comparing the two logs tells you exactly which records were dropped.
package com.bank.debug;
import java.math.BigDecimal;
import java.util.List;
import java.util.stream.Collectors;
public class DiagnoseFilter {
record Employee(String name, BigDecimal salary) {}
public static void main(String[] args) {
List<Employee> staff = List.of(
new Employee("Rajesh", new BigDecimal("60000")),
new Employee("Priya", new BigDecimal("45000")),
new Employee("Amit", new BigDecimal("80000"))
);
BigDecimal threshold = new BigDecimal("50000");
long highEarners = staff.stream()
.peek(e -> System.out.println("IN : " + e.name() + " = " + e.salary()))
.filter(e -> e.salary().compareTo(threshold) > 0)
.peek(e -> System.out.println("OUT: " + e.name() + " survived"))
.count();
System.out.println("high earners = " + highEarners);
}
}
IN : Rajesh = 60000
OUT: Rajesh survived
IN : Priya = 45000
IN : Amit = 80000
OUT: Amit survived
high earners = 2
The log shows Priya came in but never survived, so you instantly see the filter dropped her at 45,000.
How does interleaving work when you have peek at many stages?
Streams process one element all the way through before starting the next, not stage by stage. The log interleaving makes this visible and is often itself the thing a developer learns from peek.
package com.bank.debug;
import java.util.List;
import java.util.stream.Collectors;
public class PeekInterleaving {
public static void main(String[] args) {
String joined = List.of("acc-1", "acc-2").stream()
.peek(a -> System.out.println("1 source: " + a))
.map(String::toUpperCase)
.peek(a -> System.out.println("2 mapped: " + a))
.collect(Collectors.joining(","));
System.out.println(joined);
}
}
1 source: acc-1
2 mapped: ACC-1
1 source: acc-2
2 mapped: ACC-2
ACC-1,ACC-2
How is peek different from forEach and map?
peek observes and continues; forEach ends the pipeline; map transforms. Using the wrong one is a frequent source of confusion.
| Operation | Type | Ends pipeline? | Transforms? | Typical use |
|-----------|------|----------------|-------------|-------------|
| peek(Consumer) | intermediate | no | no | debug logging between stages |
| map(Function) | intermediate | no | yes | convert element to a new value |
| forEach(Consumer) | terminal | yes | no | run a side effect at the end |
List<String> ids = List.of("a", "b");
// peek: keeps the stream going
ids.stream().peek(System.out::println).count();
// forEach: terminal, returns void, cannot chain after it
ids.stream().forEach(System.out::println);
// map: transforms, so downstream sees changed values
List<String> upper = ids.stream().map(String::toUpperCase).collect(java.util.stream.Collectors.toList());
What are common mistakes when debugging with peek?
The common mistake is leaving peek in production or putting real work inside it. peek is for temporary observation; business effects do not belong there.
// WRONG: using peek to do the work, then forgetting to remove it
staff.stream()
.peek(e -> auditRepository.save(e)) // effect may be skipped, and ships to prod
.count();
// WRONG: mutating the element inside a debug peek
staff.stream()
.peek(e -> e.setProcessed(true)) // hidden mutation in a debug tool
.collect(Collectors.toList());
// CORRECT: peek only logs, and it is removed before shipping
staff.stream()
.peek(e -> log.fine(() -> "debug: processing " + e.name())) // advisory only
.collect(Collectors.toList());
// CORRECT: persistence runs in a guaranteed terminal stage
staff.forEach(auditRepository::save);
What is the edge case where count skips peek?
Since Java 9, if the stream is sized and the terminal is count(), the runtime returns the size without traversing elements, so an upstream peek never runs. This is why you must not trust peek to run for every element.
package com.bank.debug;
import java.util.List;
public class PeekSkippedByCount {
public static void main(String[] args) {
long n = List.of("a", "b", "c").stream()
.peek(x -> System.out.println("peeked: " + x)) // Java 9+: skipped
.count();
System.out.println("count = " + n);
}
}
count = 3
Nothing from peek printed, because the size was known without walking the elements.
When should you use peek and when should you remove it?
Use peek while you are diagnosing a pipeline: to see each element, to count survivors at each stage, or to understand interleaving. Keep it short and non-essential. Once the investigation is done, remove peek so production code does not depend on a stage the contract may skip and so logs stay clean.
// During investigation
report = staff.stream()
.peek(e -> System.out.println("debug: " + e)) // temporary
.filter(e -> e.salary().compareTo(threshold) > 0)
.collect(Collectors.toList());
// Shipped code: peek removed
report = staff.stream()
.filter(e -> e.salary().compareTo(threshold) > 0)
.collect(Collectors.toList());
What are common interview follow-up questions?
Expect: Will peek run if there is no terminal operation? Answer: no, because intermediate operations are lazy. Why might a peek before count print nothing? Answer: a sized stream with count can skip traversal since Java 9. How do you use peek to find why a filter drops records? Answer: place peek before and after the filter and compare the two logs. Should peek ever persist data or mutate elements? Answer: no, those are side effects that belong in map or forEach, because peek is a debugging-only observation stage you remove before production.
Visual Overview
PEEK BEFORE AND AFTER A FILTER: FIND THE DROPPED RECORDS
source peek IN filter peek OUT count
┌─────────┐ ┌──────────────┐ ┌──────────────┐ ┌─────────────┐ ┌────────┐
│ staff │───▶│ log every │─▶│ salary > │──▶│ log each │──▶│ 2 │
│ stream │ │ incoming │ │ 50000 │ │ survivor │ │ │
└─────────┘ └──────────────┘ └──────────────┘ └─────────────┘ └────────┘
│ │ │
▼ ▼ ▼
IN: Rajesh drops Priya OUT: Rajesh
IN: Priya (45000) OUT: Amit
IN: Amit
Compare logs: IN has 3 names, OUT has 2 -> Priya was dropped by the filter
LAZY: NO TERMINAL MEANS NO PEEK
stream().peek(print) ──▶ nothing prints (no pull)
stream().peek(print).count() ──▶ pulls elements, peek prints (pre Java 9)
stream().peek(print).count() ──▶ SIZED, size returned, peek SKIPPED (Java 9+)
Key Takeaway
When a stream report gives a wrong answer, peek is how you look inside it. It takes a consumer, runs it on each element as the element passes that stage, and hands the same element downstream unchanged. You use it to watch elements move through a pipeline and to see which records survive each filter or map, which is exactly how you diagnose a report at the National Bank of India that returns an unexpected count.
In practice Rajesh places one peek before a filter to log every incoming employee and another peek after the filter to log only the survivors. The difference between the two logs tells him precisely which records were dropped and why. Because peek is lazy, nothing runs until a terminal operation such as collect or count pulls the elements, so a pipeline that ends at peek does nothing, and the log interleaving also reveals that streams push one element all the way through before starting the next.
The point to always add is that peek is strictly for observation. The consumer must not mutate elements or external state, and since Java 9 the pipeline can skip peek entirely when it can compute the result without traversing elements, as a sized stream ending in count does. So peek is never where business logic lives.
Treat peek as a temporary diagnostic that you add while investigating and remove before the code ships. Real transformations go in map, and guaranteed terminal effects go in forEach or a collector. That keeps production code honest and logs clean while still giving you a clear window during debugging.
Remember one sentence: peek is a notebook you hold beside the belt to watch forms go by, not a stamp you use to change them.
---