Back to Stream: Complex Pipelines
Q1Question 1 of 8

Audit each element flowing through a pipeline using peek without changing the data

Key Concepts
  • peek(Consumer) performs a side effect on each element and passes it downstream unchanged
  • peek is an intermediate, lazy operation: nothing runs until a terminal operation pulls elements
  • Placed after a filter, peek observes only the elements that survived the filter
  • peek is intended for debugging and lightweight auditing, not for transforming data
  • An optimizing pipeline may skip peek when the result can be computed without visiting elements
  • Use map for real transformations and forEach for guaranteed terminal side effects
  • In parallel streams the peek Consumer must be thread-safe because it runs on many threads

Definition

`peek` lets you watch each element go by for logging or auditing without modifying it; it is a side-effect window, not a transformer.

peek is a narrow tool with one honest job: observe.

  • peek(Consumer) – applies the consumer to each element as it flows past, then emits the same element unchanged to the next stage.
  • Intermediate and lazy – the consumer runs only while a terminal operation pulls elements through the pipeline, so stream().peek(...) with no terminal does nothing.
  • Position matters – a peek placed after a filter sees only the elements that passed the filter, so it is ideal for auditing survivors.
  • Debugging first – the Stream contract says peek exists mainly to support debugging, not to carry essential logic.
  • May be skipped – an optimizing pipeline can legally bypass peek when it can produce the result without visiting every element.

Imagine a conveyor belt of account forms at the National Bank of India. peek is a CCTV camera mounted over the belt: it photographs each form for the audit log as the form passes, but it never changes or removes a form. The forms continue down the belt exactly as they were. Priya the compliance officer trusts the camera to watch, but she would never ask it to approve a loan, because watching is all it is built to do.

Understanding the Concepts

What is Stream.peek?

peek takes a Consumer and runs it on each element as that element passes through the peek stage, then returns the identical element to the next stage. It is an intermediate operation, which means it returns a new Stream and does not end the pipeline. Its signature is Stream peek(Consumer action).

The key property is that peek does not transform or remove anything. Whatever element arrives is exactly the element that leaves. The only thing peek adds is a side effect, usually logging.

JAVA
package com.bank.audit;

import java.math.BigDecimal;
import java.util.List;
import java.util.stream.Collectors;

public class PeekBasics {
    record Employee(String name, String department, BigDecimal salary) {}

    public static void main(String[] args) {
        List<Employee> staff = List.of(
            new Employee("Rajesh", "Trading", new BigDecimal("1200000")),
            new Employee("Priya", "Risk", new BigDecimal("800000"))
        );

        List<String> names = staff.stream()
            .peek(e -> System.out.println("saw: " + e.name()))
            .map(Employee::name)
            .collect(Collectors.toList());

        System.out.println(names);
    }
}
TEXT
saw: Rajesh
saw: Priya
[Rajesh, Priya]

Why does peek exist?

peek exists so you can observe a pipeline without breaking it apart. Before peek, the only way to see what flowed between two stages was to split the pipeline, collect to an intermediate list, print it, and rebuild. peek lets you drop a single line between any two stages and watch elements go by, then remove it when you are done.

The Javadoc states its purpose plainly: peek exists mainly to support debugging, where you want to see elements as they flow past a point in the pipeline.

JAVA
// Without peek you must break the pipeline to inspect it
List<Employee> afterFilter = staff.stream()
    .filter(e -> e.salary().compareTo(new BigDecimal("1000000")) > 0)
    .collect(Collectors.toList());
System.out.println("survivors: " + afterFilter); // extra step
List<String> names = afterFilter.stream().map(Employee::name).collect(Collectors.toList());

// With peek you inspect in place, in one fluent pipeline
List<String> namesWithPeek = staff.stream()
    .filter(e -> e.salary().compareTo(new BigDecimal("1000000")) > 0)
    .peek(e -> System.out.println("survivor: " + e.name()))
    .map(Employee::name)
    .collect(Collectors.toList());

Why is peek an intermediate operation and what does lazy mean here?

peek is intermediate because it returns a Stream, so you can keep chaining. Intermediate operations are lazy: they describe work but do not perform it until a terminal operation such as collect, forEach, or count requests elements. This means a pipeline that ends at peek runs nothing at all.

JAVA
package com.bank.audit;

import java.util.List;

public class PeekLaziness {
    public static void main(String[] args) {
        // No terminal operation: peek never runs, nothing prints
        List.of("Rajesh", "Priya").stream()
            .peek(name -> System.out.println("peeked: " + name));
        System.out.println("done, but peek printed nothing above");
    }
}
TEXT
done, but peek printed nothing above

How do you use peek to audit survivors of a filter?

Because peek runs only for elements that reach its stage, placing it after a filter means it observes only the survivors. This is the auditing pattern: filter high earners, then peek to log each one that passed, then map and collect the final result.

JAVA
package com.bank.audit;

import java.math.BigDecimal;
import java.util.List;
import java.util.logging.Logger;
import java.util.stream.Collectors;

public class AuditSurvivors {
    private static final Logger log = Logger.getLogger(AuditSurvivors.class.getName());
    record Employee(String name, String department, BigDecimal salary) {}

    public static void main(String[] args) {
        List<Employee> staff = List.of(
            new Employee("Rajesh", "Trading", new BigDecimal("1200000")),
            new Employee("Priya", "Risk", new BigDecimal("800000")),
            new Employee("Amit", "Trading", new BigDecimal("1500000"))
        );

        BigDecimal threshold = new BigDecimal("1000000");

        List<String> highEarnerNames = staff.stream()
            .filter(e -> e.salary().compareTo(threshold) > 0)
            .peek(e -> log.info(() -> "passed filter: " + e.name()))
            .map(Employee::name)
            .collect(Collectors.toList());

        System.out.println(highEarnerNames);
    }
}
TEXT
INFO: passed filter: Rajesh
INFO: passed filter: Amit
[Rajesh, Amit]

Priya in Risk never appears in the audit log because she did not survive the filter, which is exactly the auditing behaviour compliance wanted.

How do you use peek at multiple stages to diagnose a pipeline?

Sprinkling peek at several stages shows how many elements survive each step. The difference between two logs tells you precisely which records were dropped and where.

JAVA
package com.bank.audit;

import java.math.BigDecimal;
import java.util.List;
import java.util.stream.Collectors;

public class MultiStagePeek {
    record Employee(String name, String department, BigDecimal salary) {}

    public static void main(String[] args) {
        List<Employee> staff = List.of(
            new Employee("Rajesh", "Trading", new BigDecimal("1200000")),
            new Employee("Priya", "Risk", new BigDecimal("800000"))
        );

        String result = staff.stream()
            .peek(e -> System.out.println("1-incoming: " + e.name()))
            .filter(e -> e.department().equals("Trading"))
            .peek(e -> System.out.println("2-after dept filter: " + e.name()))
            .map(Employee::name)
            .peek(n -> System.out.println("3-after map: " + n))
            .collect(Collectors.joining(", "));

        System.out.println("result: " + result);
    }
}
TEXT
1-incoming: Rajesh
2-after dept filter: Rajesh
3-after map: Rajesh
1-incoming: Priya
result: Rajesh

Note the interleaving: each element is pulled all the way through before the next starts, because streams process elements one at a time, not stage by stage.

How is peek different from map and forEach?

These three are easy to confuse because all accept a function. The difference is what they return and whether they transform.

| Operation | Type | Returns a value? | Transforms element? | Ends pipeline? | Guaranteed to run? |
|-----------|------|------------------|---------------------|----------------|--------------------|
| peek(Consumer) | intermediate | emits same element | no | no | no, may be skipped |
| map(Function) | intermediate | emits mapped element | yes | no | yes, if reached |
| forEach(Consumer) | terminal | nothing (void) | no | yes | yes, for every element |

JAVA
package com.bank.audit;

import java.util.List;
import java.util.stream.Collectors;

public class PeekVsMapVsForEach {
    public static void main(String[] args) {
        List<String> accounts = List.of("acc-1", "acc-2");

        // peek: observe, do not change
        List<String> viaPeek = accounts.stream()
            .peek(a -> System.out.println("peek: " + a))
            .collect(Collectors.toList());
        System.out.println(viaPeek); // [acc-1, acc-2]

        // map: transform the element
        List<String> viaMap = accounts.stream()
            .map(String::toUpperCase)
            .collect(Collectors.toList());
        System.out.println(viaMap); // [ACC-1, ACC-2]

        // forEach: terminal side effect, returns nothing
        accounts.stream().forEach(a -> System.out.println("forEach: " + a));
    }
}
TEXT
peek: acc-1
peek: acc-2
[acc-1, acc-2]
[ACC-1, ACC-2]
forEach: acc-1
forEach: acc-2

What are common mistakes with peek?

The most dangerous mistake is putting essential logic inside peek. An optimizing pipeline may skip peek, and relying on it for business work leads to silent data loss.

JAVA
// WRONG: mutating or persisting inside peek, treating it as a transform
List<Employee> saved = staff.stream()
    .peek(e -> database.save(e))          // may not run for every element
    .peek(e -> e.setAudited(true))        // mutation hidden in a debug tool
    .collect(Collectors.toList());

// WRONG: using peek to transform, expecting the change to stick
staff.stream()
    .peek(e -> e.getName().toUpperCase()) // result thrown away, element unchanged
    .collect(Collectors.toList());
JAVA
// CORRECT: persistence and transformation live in guaranteed stages
List<Employee> saved = staff.stream()
    .map(e -> {                            // map is guaranteed to run if reached
        database.save(e);
        return e.withAudited(true);        // return the transformed value
    })
    .collect(Collectors.toList());

// CORRECT: peek is used only for logging
List<String> upper = staff.stream()
    .peek(e -> log.fine(() -> "processing " + e.name()))
    .map(e -> e.name().toUpperCase())
    .collect(Collectors.toList());

What are the edge cases where peek is skipped?

Since Java 9, when the stream is SIZED and the terminal operation does not need element values, the pipeline may skip peek entirely. The classic case is count() on a sized stream.

JAVA
package com.bank.audit;

import java.util.List;

public class PeekSkipped {
    public static void main(String[] args) {
        // Java 9+: count can be computed from the known size, so peek is skipped
        long n = List.of("a", "b", "c").stream()
            .peek(x -> System.out.println("peeked: " + x))
            .count();
        System.out.println("count = " + n);
    }
}
TEXT
count = 3

Notice nothing from the peek printed: the size was known to be three without walking the elements. This is why peek must never carry logic the program depends on.

What about thread safety in parallel streams?

In a parallel stream the peek Consumer runs on multiple threads at once. If it touches shared mutable state it must be thread-safe, and the order of the log lines is not deterministic.

JAVA
package com.bank.audit;

import java.util.List;
import java.util.concurrent.atomic.LongAdder;

public class PeekParallel {
    public static void main(String[] args) {
        LongAdder seen = new LongAdder(); // thread-safe counter
        List<Integer> ids = List.of(1, 2, 3, 4, 5, 6, 7, 8);

        long sum = ids.parallelStream()
            .peek(id -> seen.increment())  // safe: LongAdder handles concurrency
            .mapToLong(Integer::longValue)
            .sum();

        System.out.println("sum = " + sum + ", peeked count = " + seen.sum());
    }
}
TEXT
sum = 36, peeked count = 8

A plain int counter++ inside peek here would race and undercount, which is why only thread-safe constructs belong in a parallel peek.

When should you use peek and when should you not?

Use peek when you want a temporary, non-essential observation of a pipeline: logging elements during development, auditing which records survived a filter in a way that is advisory rather than mandatory, or measuring how many elements reach a stage.

Do not use peek for anything the correctness of the program depends on: persisting records, mutating elements, incrementing counters you later read as business data, or sending notifications. For a real transformation use map; for a guaranteed terminal side effect over every element use forEach or a custom collector.

JAVA
// Good use: advisory debug logging that can be removed safely
stream.peek(e -> log.fine(() -> "stage reached by " + e.id())).collect(toList());

// Bad use moved to the right tool: guaranteed effect belongs in forEach
stream.forEach(e -> notificationService.notify(e)); // runs for every element

What are common interview follow-up questions?

Interviewers often probe whether you understand that peek is advisory. Expect: Why might peek not run for every element? Answer: because since Java 9 a SIZED stream with a size-only terminal like count can skip traversal. What is the difference between peek and map? Answer: map transforms and returns a new value, peek observes and returns the same element. Can peek change the data? Answer: it can mutate a mutable object it receives, but that is an anti-pattern, and it cannot replace the element in the stream. Where should side effects go instead? Answer: into map for transformations or forEach and collectors for terminal effects, both of which the contract guarantees to execute.

Visual Overview

DIAGRAM
PEEK IN A PIPELINE: OBSERVE, DO NOT CHANGE

  source           filter            peek              map            collect
┌─────────┐   ┌──────────────┐   ┌─────────┐   ┌──────────────┐   ┌──────────┐
│ staff   │──▶│ salary >     │──▶│ log the │──▶│ Employee     │──▶│ List of  │
│ stream  │   │ 1,000,000    │   │ survivor│   │ ::name       │   │ names    │
└─────────┘   └──────────────┘   └─────────┘   └──────────────┘   └──────────┘
                     │                │
                     ▼                ▼
               drops Priya      element unchanged,
               (800,000)        only logged


LAZY EXECUTION: NOTHING RUNS UNTIL THE TERMINAL PULLS

   stream().peek(...)              stream().peek(...).collect(...)
   ┌───────────────┐               ┌───────────────┐
   │ no terminal   │               │ terminal pulls│
   │ peek runs 0x  │               │ peek runs per │
   │ (prints none) │               │ element       │
   └───────────────┘               └───────────────┘


SKIP OPTIMIZATION (JAVA 9+): SIZED STREAM + count()

   List.of(a,b,c).stream().peek(print).count()
            │
            ▼
   ┌────────────────────┐
   │ size known = 3     │  ──▶  returns 3
   │ elements NOT walked│       peek NEVER invoked
   └────────────────────┘

Key Takeaway

When you need to watch a stream without disturbing it, peek is the right tool. It applies a side effect, usually logging, to each element as it flows past, then passes that same element downstream unchanged. It is intermediate and lazy, so the consumer runs only as a terminal operation pulls elements through, and only for the elements that actually reach the peek stage. A peek placed after a filter therefore sees just the survivors, which is exactly what makes it useful for auditing.

In our work at the National Bank of India, Priya from compliance wanted an audit trail of which high earners passed a salary filter, without changing the report's output. Rajesh dropped a peek right after the filter to log each passing employee, and the mapping and collection downstream produced the identical list of names as before. The camera watched the belt; it never touched a form.

The reason peek must stay advisory is that an optimizing pipeline can legally skip it. Since Java 9, a sized stream ending in count returns the size without traversing elements, so any upstream peek never fires. That is a documented optimization, not a bug, which is precisely why it is dangerous: the code looks correct and often works until a terminal operation or a JDK upgrade changes how the stream is traversed.

So the discipline is clear. Use peek for debugging and lightweight, non-essential auditing that you could delete with no loss of correctness. Put real transformations in map, and put guaranteed terminal side effects in forEach or a collector. In a parallel stream, make the peek consumer thread-safe, because it runs on many threads and the log order is not fixed.

Remember one sentence: peek is a window you look through, not a desk you work at, so never trust it with anything the program must not lose.

---

Share
Use ← → arrow keys to navigate