Java Stream peek(): How It Works and When It Is Skipped

Java Stream peek() explained with element order, cases where count(), findFirst() and limit() skip it, peek vs map vs forEach, parallel streams and logging

Three elements flowing one at a time through peek, map and forEach in a Java stream

The Stream.peek() method is an intermediate operation that returns a stream with the same elements and runs a Consumer action on each element when the next operation pulls it. The elements pass through unchanged, so peek() lets us look at a stream pipeline between two steps without changing its result.

We use peek() mainly to see which elements reach a certain point of a stream while debugging, and sometimes to write DEBUG log lines in a pipeline. The method exists mainly to support debugging, and its behavior explains why it is a poor place for business logic.

The following example records the words that pass the filter before they are converted to uppercase.

List<String> seen = new ArrayList<>();
List<String> loud = Stream.of("hum", "beep", "buzz", "hi").filter(w -> w.length() > 3).peek(seen::add).map(String::toUpperCase).toList();   // [BEEP, BUZZ]
List<String> passedFilter = seen;                                  // [beep, buzz]
List<String> counted = new ArrayList<>();
long total = Stream.of("hum", "beep").peek(counted::add).count();  // 2
List<String> peekedBeforeCount = counted;                          // [], count() skipped the pipeline

Notice the last line. The first pipeline ran the peek() action for every word that passed the filter, while the second one never ran it at all. We look at the order in which peek() sees elements, the cases where the action is skipped, how peek() compares with map() and forEach(), parallel streams and a logging example from a sensor data import.

1. How peek() Sees Each Element

The signature is Stream<T> peek(Consumer<? super T> action). Like every intermediate operation, peek() is lazy, so calling it only adds a step to the pipeline. Nothing runs until a terminal operation such as toList() or forEach() starts pulling elements.

A sequential stream processes one element through all steps before it starts the next element. The action of peek() therefore interleaves with the other steps instead of running for the whole list first.

Three elements flowing one at a time through peek, map and forEach in a Java stream
Each element goes through peek(), map() and forEach() before the next element starts, so the output interleaves
Stream.of(3, 8, 1).peek(n -> System.out.println("read " + n)).map(n -> n * 10).forEach(n -> System.out.println("got " + n));
read 3
got 30
read 8
got 80
read 1
got 10

The interleaved output is what makes peek() useful for debugging, because each line shows the state of one element at one point. We cover breakpoints, IntelliJ stream tracing and other debugging tools in debugging Java streams.

2. When the peek() Action Does Not Run

A stream may skip the peek() action for some elements or for all of them, because a Stream implementation is allowed to leave out steps that do not change the result. Code that relies on peek() running for every element can therefore break without any error.

SituationWhat happens to the peek() action
No terminal operationNever runs, because the pipeline never starts
count() on a source of known size (Java 9+)Skipped when the size can be computed without the elements
Short-circuiting terminal operation, such as findFirst() or anyMatch()Runs only for the elements pulled before the answer is known
limit(n) later in the pipelineRuns only for the elements that limit() lets through
sorted() later in the pipelineRuns for all elements before the first sorted element moves on

2.1. count() on a Stream of Known Size

Since Java 9, count() returns the size of a list or an array without walking the pipeline if no step can change the number of elements. A map() keeps the size, so the stream still skips the work, whereas a filter() can change the size, so the stream has to run every step.

List<Integer> mapped = new ArrayList<>();
long size = List.of(5, 6, 7).stream().peek(mapped::add).map(n -> n * 2).count();       // 3
List<Integer> afterMap = mapped;                                                       // []
List<Integer> filtered = new ArrayList<>();
long bigOnes = List.of(5, 6, 7).stream().peek(filtered::add).filter(n -> n > 5).count();   // 2
List<Integer> afterFilter = filtered;                                                  // [5, 6, 7]

2.2. Short-Circuiting and limit()

Operations that can stop early pull only the elements they need. The peek() action therefore shows how far the stream went, which is useful for checking that an infinite stream or a costly source stops in time.

List<Integer> pulled = new ArrayList<>();
Optional<Integer> firstEven = Stream.of(3, 5, 6, 8, 9).peek(pulled::add).filter(n -> n % 2 == 0).findFirst();   // Optional[6]
List<Integer> visited = pulled;                                                                                 // [3, 5, 6]
List<Integer> generated = new ArrayList<>();
List<Integer> firstTwo = Stream.iterate(1, n -> n + 1).peek(generated::add).limit(2).toList();                  // [1, 2]
List<Integer> created = generated;                                                                              // [1, 2]

2.3. peek() Before sorted()

The sorted() operation must see every element before it can return the smallest one. A peek() before it runs for the whole stream first, and a peek() after it sees the elements in sorted order.

List<String> events = new ArrayList<>();
List<Integer> sortedNumbers = Stream.of(3, 1, 2).peek(n -> events.add("in " + n)).sorted().peek(n -> events.add("out " + n)).toList();   // [1, 2, 3]
List<String> order = events;                                                                                                             // [in 3, in 1, in 2, out 1, out 2, out 3]

3. peek() vs map() vs forEach()

The three methods all take a lambda per element, which is why they get mixed up. The difference is what they return and whether the action is guaranteed to run.

MethodKindReturnsAction guaranteed to runUse it to
peek(Consumer)IntermediateThe same elementsNoLook at elements while debugging
map(Function)IntermediateNew elementsFor every element the result needsTransform elements
forEach(Consumer)TerminalNothingYes, for every elementRun a side effect at the end

Changing elements inside peek() compiles when the elements are mutable, but the change depends on the action running. In the following example, count() skips the pipeline, so the names are never capitalized.

List<StringBuilder> names = List.of(new StringBuilder("ann"), new StringBuilder("bob"));
long changed = names.stream().peek(sb -> sb.setCharAt(0, 'A')).count();       // 2
String firstName = names.getFirst().toString();                                // "ann", peek() never ran

The reliable version transforms with map() and returns new values, which also works with immutable types such as String and records.

List<String> capitalized = Stream.of("ann", "bob").map(s -> Character.toUpperCase(s.charAt(0)) + s.substring(1)).toList();   // [Ann, Bob]

4. peek() in Parallel Streams

In a parallel stream, the action may run at any time and in any thread that has the element. The order of the peek() calls changes from run to run, even though toList() keeps the result in encounter order, and an action that writes to shared state needs a thread-safe target.

Queue<Integer> seenInParallel = new ConcurrentLinkedQueue<>();
List<Integer> doubled = IntStream.rangeClosed(1, 6).boxed().parallel().peek(seenInParallel::add).map(n -> n * 2).toList();   // [2, 4, 6, 8, 10, 12]
int seenCount = seenInParallel.size();                                                                                       // 6, in an order that varies

With a plain ArrayList instead of ConcurrentLinkedQueue, the same code can lose elements or throw ArrayIndexOutOfBoundsException, because several threads call add() at once.

5. Logging a Sensor Data Import With peek()

A weather station uploads lines such as s1,21.5, one per sensor. The import service skips malformed lines and turns the rest into readings, and while we investigate a bug we want DEBUG log lines that show which lines passed validation, without changing the import result.

record Reading(String sensor, double celsius) {}
System.Logger log = System.getLogger("sensor-import");
Predicate<String> wellFormed = Pattern.compile("\\w+,-?\\d+(\\.\\d+)?").asMatchPredicate();
List<String> upload = List.of("s1,21.5", "s2,abc", "s3,-4.0");
List<Reading> readings = upload.stream()
        .filter(wellFormed)
        .peek(line -> log.log(System.Logger.Level.DEBUG, "valid line {0}", line))
        .map(line -> line.split(","))
        .map(parts -> new Reading(parts[0], Double.parseDouble(parts[1])))
        .toList();
List<Reading> imported = readings;                     // [Reading[sensor=s1, celsius=21.5], Reading[sensor=s3, celsius=-4.0]]

The DEBUG lines appear only when the logger for sensor-import is set to DEBUG, so the peek() call can stay in the code without filling the production logs. We read the number of rejected lines from the data with upload.size() – readings.size(), not from a counter inside peek(), because a counter would give a wrong number as soon as someone replaces toList() with count().

6. Stream peek() FAQs

Code reviews and search results keep returning to the same peek() questions.

6.1. Should peek() Be Used in Production Code?

Only for logging or metrics that may be skipped without harm. The stream may elide the action, as section 2 shows, so peek() must never change data, call a service or count something that the program depends on.

6.2. Why Is peek() Skipped Before count()?

Since Java 9, count() computes the size from the source when the source size is known and no step can change it, such as a list followed only by peek() and map(). A filter() or flatMap() in the pipeline makes the stream run every step, because the size is no longer known.

6.3. What Is the Difference Between peek() and map()?

The peek() method returns the same elements and only looks at them, whereas map() returns the result of its function for each element. When we want different elements, we use map(), even if the new element is a changed copy of the old one.

6.4. Does peek() Change the Order of Elements?

No. The elements leave peek() in the order they arrive. In a parallel stream the action itself runs in an unpredictable order, but the stream still keeps its encounter order for operations such as toList() and forEachOrdered().

6.5. What Is the Difference Between peek() and forEach()?

The forEach() method is a terminal operation that ends the stream and runs the action for every element, whereas peek() is an intermediate operation that returns a stream and needs a later terminal operation. A pipeline that ends with peek() does nothing at all.

7. Conclusion

The peek() method passes each element on unchanged and runs an action as the element moves through the pipeline. In a sequential stream the action interleaves with the other steps, which makes it a good tool to see what a filter or a map did to the data.

The action can be skipped by count(), short-circuiting operations and limit(), and it runs in any order in parallel streams. We keep it for debugging and DEBUG logging, and we use map() to change elements and forEach() for side effects that must happen.

8. References

Happy Learning !!

Source Code on Github

About Us

HowToDoInJava provides tutorials and how-to guides on Java and related technologies.

It also shares the best practices, algorithms & solutions and frequently asked interview questions.