Intermediate Operations
Intermediate operations are the building blocks that shape a Java Stream pipeline. They allow you to filter elements, transform values, sort data, remove duplicates, and perform many other forms of processing before the final result is produced.
The most important characteristic of an intermediate operation is that it does not normally finish the stream pipeline. Instead, it returns another stream, allowing multiple operations to be connected together.
What Is an Intermediate Operation?
An intermediate operation takes the current stream and returns a new stream that represents another stage of processing. This makes it possible to build readable pipelines where each operation performs one focused task.
import java.util.List;
public class IntermediateExample {
public static void main(String[] args) {
List<String> names = List.of(
"Amit",
"Anita",
"Rahul",
"Arjun"
);
names.stream()
.filter(name -> name.startsWith("A"))
.map(String::toUpperCase)
.forEach(System.out::println);
}
}
The filter() operation keeps names beginning with A. The map() operation converts those names to uppercase. Both are intermediate operations because they continue the stream pipeline rather than consuming it.
Important: Intermediate operations are generally lazy. They describe the processing that should happen, but the actual pipeline processing normally begins only when a terminal operation is invoked.
Common Intermediate Operations
| Operation | Purpose | Returns |
|---|---|---|
| filter() | Keeps elements that satisfy a condition | Stream |
| map() | Transforms each element | Stream |
| flatMap() | Flattens nested streams | Stream |
| sorted() | Orders stream elements | Stream |
| distinct() | Removes duplicate elements | Stream |
| limit() | Keeps only a specified number of elements | Stream |
| skip() | Ignores the first specified elements | Stream |
| peek() | Allows observation of elements during processing | Stream |
Intermediate Operations Are Lazy
Consider the following code:
import java.util.List;
public class LazyOperation {
public static void main(String[] args) {
List<Integer> numbers = List.of(10, 20, 30);
numbers.stream()
.filter(number -> {
System.out.println("Checking " + number);
return number > 10;
});
System.out.println("Finished");
}
}
You might expect the filtering code to print something. It does not. The intermediate operation has only built part of the pipeline. Nothing consumes the stream.
Add a terminal operation:
numbers.stream()
.filter(number -> {
System.out.println("Checking " + number);
return number > 10;
})
.forEach(System.out::println);
Now the stream is consumed, so the intermediate operation actually participates in processing.
Remember: Think of intermediate operations as instructions written on a processing pipeline. The instructions describe the work; a terminal operation starts the actual journey through the pipeline.
filter() as an Intermediate Operation
The filter() operation keeps only elements that satisfy a given condition. It is one of the most frequently used intermediate operations.
import java.util.List;
public class FilterExample {
public static void main(String[] args) {
List<Integer> numbers = List.of(
5, 12, 18, 21, 30
);
numbers.stream()
.filter(number -> number > 15)
.forEach(System.out::println);
}
}
Only values greater than 15 continue through the pipeline. The original list is not changed.
map() as an Intermediate Operation
The map() operation transforms each element into another value.
import java.util.List;
public class MapExample {
public static void main(String[] args) {
List<String> names = List.of(
"Amit",
"Priya",
"Rahul"
);
names.stream()
.map(String::toUpperCase)
.forEach(System.out::println);
}
}
Each string is transformed into its uppercase representation. The number of elements normally remains the same, although the values themselves change.
Combining Intermediate Operations
The real strength of intermediate operations appears when they are combined into a pipeline.
import java.util.List;
public class PipelineExample {
public static void main(String[] args) {
List<Integer> numbers = List.of(
5, 12, 18, 25, 30, 41
);
numbers.stream()
.filter(number -> number > 10)
.map(number -> number * 2)
.forEach(System.out::println);
}
}
The pipeline first removes values that are not greater than 10. The remaining values are then doubled. Finally, the terminal operation prints the results.
This reads almost like a sentence: take the numbers, keep those above 10, double them, and process the results. That expressive style is one of the major reasons streams are popular in modern Java code.
Intermediate Operations Can Change the Number of Elements
Not every intermediate operation preserves the number of elements. This is an important distinction when designing a pipeline.
| Operation | Effect on Element Count |
|---|---|
| filter() | Can reduce the number of elements |
| map() | Normally preserves the number of elements |
| flatMap() | Can increase, reduce, or preserve the number of elements |
| distinct() | Can reduce the number of elements |
| limit() | Can reduce the number of elements |
| skip() | Can reduce the number of elements |
| sorted() | Normally preserves the number of elements |
Ordering Intermediate Operations
The order of intermediate operations can affect both the meaning and performance of a pipeline. If an inexpensive filtering condition can remove many elements, placing it earlier can reduce the amount of work performed by later operations.
numbers.stream()
.filter(number -> number > 100)
.map(number -> expensiveTransformation(number))
.forEach(System.out::println);
Here, the filter runs before the transformation. If most numbers are rejected, the potentially expensive transformation runs for fewer elements.
Industry Tip: When several operations are possible, place selective and inexpensive filtering operations early when doing so preserves the required behavior. This can reduce unnecessary processing.
Short-Circuiting Intermediate Operations
Some stream operations can limit how much data needs to flow through the pipeline. For example, limit() can stop processing after the required number of elements has been obtained.
import java.util.stream.Stream;
public class LimitExample {
public static void main(String[] args) {
Stream.iterate(1, number -> number + 1)
.limit(5)
.forEach(System.out::println);
}
}
The generated stream could continue indefinitely, but limit(5) restricts the pipeline to five elements.
Stateful and Stateless Intermediate Operations
Intermediate operations can also be considered in terms of whether processing one element requires information about other elements.
| Type | Examples | General Idea |
|---|---|---|
| Stateless | filter(), map() | Each element can generally be processed without remembering previous elements |
| Stateful | sorted(), distinct() | Processing may require information about multiple elements |
This distinction becomes particularly important when discussing stream performance and parallel processing. Operations such as sorting may require more coordination than straightforward filtering or mapping.
Intermediate Operations Do Not Usually Modify the Source
A stream pipeline does not automatically mutate the collection from which the stream was created.
import java.util.List;
public class SourceUnchanged {
public static void main(String[] args) {
List<String> names = List.of(
"Amit",
"Priya",
"Rahul"
);
names.stream()
.map(String::toUpperCase)
.forEach(System.out::println);
System.out.println(names);
}
}
The stream produces uppercase values, but the original list still contains the original strings.
A Practical Employee Example
Imagine an application that stores employee records. You want to select employees with salaries above a threshold and then extract their names.
import java.util.List;
public class EmployeePipeline {
record Employee(String name, double salary) {}
public static void main(String[] args) {
List<Employee> employees = List.of(
new Employee("Amit", 45000),
new Employee("Priya", 72000),
new Employee("Rahul", 58000),
new Employee("Neha", 85000)
);
employees.stream()
.filter(employee -> employee.salary() > 60000)
.map(Employee::name)
.forEach(System.out::println);
}
}
The first intermediate operation selects employees whose salary exceeds 60000. The second transforms each employee object into a name. Notice how the pipeline changes the type of data flowing through it: the source contains Employee objects, while the later stage contains strings.
Intermediate Operations vs Terminal Operations
| Intermediate Operation | Terminal Operation |
|---|---|
| Returns another stream | Produces a final result or action |
| Normally lazy | Triggers stream processing |
| Can be chained | Normally ends the pipeline |
| Examples: filter(), map(), sorted() | Examples: collect(), count(), forEach() |
Common Beginner Mistakes
- Expecting immediate execution: Intermediate operations are generally lazy and need a terminal operation to trigger processing.
- Trying to use an intermediate operation as the final result: Calling filter() alone does not normally produce the final collection or value you want.
- Assuming every operation preserves element count: Operations such as filter() and distinct() can remove elements.
- Ignoring operation order: A poorly ordered pipeline may perform expensive work on elements that could have been rejected earlier.
- Mutating external state inside lambdas: Side effects can make stream pipelines harder to reason about, especially when parallel streams are involved.
- Making pipelines unnecessarily long: A stream should improve clarity, not turn a simple operation into an unreadable chain.
Best Practices
- Give each intermediate operation a clear purpose.
- Use filtering early when it can safely reduce the amount of later processing.
- Prefer stateless operations when possible.
- Avoid unnecessary side effects inside stream pipelines.
- Keep complex business logic in well-named methods when lambdas become difficult to read.
- Use intermediate operations to express transformations clearly rather than forcing every operation into a stream.
Interview Insights
Question: What is an intermediate operation in Java Streams?
Answer: It is an operation that processes a stream and returns another stream, allowing additional operations to be chained. Intermediate operations are generally lazy.
Question: Why are intermediate operations called lazy?
Answer: They normally do not process elements when the pipeline is being constructed. Processing begins when a terminal operation consumes the stream.
Question: Can multiple intermediate operations be chained?
Answer: Yes. Operations such as filter(), map(), sorted(), and distinct() can be combined into a pipeline before a terminal operation.
Quick Revision
| Concept | Key Point |
|---|---|
| Intermediate operation | Returns another stream and can be chained |
| Lazy evaluation | Processing generally waits for a terminal operation |
| filter() | Removes elements that do not satisfy a condition |
| map() | Transforms each element |
| flatMap() | Flattens nested structures into a single stream |
| sorted() | Orders stream elements |
| distinct() | Removes duplicates |
| limit() | Restricts the number of elements flowing through the pipeline |
| Operation order | Can affect both readability and performance |
Final Takeaway
Intermediate operations are what give Java streams their pipeline-oriented style. They allow you to progressively shape data without immediately producing a final result. Once you understand that operations such as filter(), map(), flatMap(), sorted(), and distinct() are lazy, chainable steps, stream code becomes much easier to read and design. The next important step is learning how terminal operations finally consume these pipelines and turn processing into useful results.
