Stream API: map, filter, reduce and collect through examples
1. Overview
Stream API has been a staple since Java 8 and is an indispensable tool when working with collections. This article helps you master the 4 most important operations: map, filter, reduce, and collect through concrete examples with Order and Customer. After reading, you’ll know which operation to use when, how to combine them, and avoid common mistakes when working with streams.
2. Preparing sample data
Before diving into each operation, you’ll need some sample data to work with throughout this article.
public record Order(String id, String product, int quantity, double price, String status) {
public double total() {
return quantity * price;
}
}
import java.util.List;
public class OrderData {
public static List<Order> sample() {
return List.of(
new Order("O1", "Laptop", 1, 15_000_000, "COMPLETED"),
new Order("O2", "Mouse", 3, 150_000, "COMPLETED"),
new Order("O3", "Keyboard", 2, 400_000, "CANCELLED"),
new Order("O4", "Monitor", 1, 3_500_000, "COMPLETED"),
new Order("O5", "Laptop", 1, 14_000_000, "PENDING")
);
}
}
Order is a record – concise, immutable, and perfect for demonstrating streams. The total() method calculates the total amount for each order and will be reused several times below.
3. filter: filtering elements by condition
filter takes a Predicate<T> and keeps only elements that satisfy the condition.
List<Order> completedOrders = OrderData.sample().stream()
.filter(order -> order.status().equals("COMPLETED"))
.toList();
completedOrders.forEach(o -> System.out.println(o.id() + " - " + o.product()));
stream()convertsList<Order>into aStream<Order>.filterkeeps only orders withstatusequal toCOMPLETED.toList()(Java 16+) collects the result into an immutableList, replacingcollect(Collectors.toList()).
O1 - Laptop
O2 - Mouse
O4 - Monitor
Note:
filterdoesn’t modify the original data. Streams are lazy – if you don’t have a terminal operation (liketoList(),forEach()), the precedingfilter/mapcalls will not execute.
3.1. Combining multiple filter conditions
List<Order> expensiveCompleted = OrderData.sample().stream()
.filter(o -> o.status().equals("COMPLETED"))
.filter(o -> o.total() > 1_000_000)
.toList();
Two consecutive filter calls are equivalent to an AND condition. You could combine them into a single filter with &&, but separating them makes the code more readable when conditions are complex.
4. map: transforming elements
map takes a Function<T, R> and transforms each element of type T into an element of type R.
List<String> productNames = OrderData.sample().stream()
.map(Order::product)
.distinct()
.toList();
System.out.println(productNames);
Order::productis a method reference, equivalent toorder -> order.product().distinct()removes duplicate elements (based onequals()).
[Laptop, Mouse, Keyboard, Monitor]
4.1. map returning a completely different type
The strength of map is transforming data types, not just extracting a field.
List<Double> totals = OrderData.sample().stream()
.map(Order::total)
.toList();
System.out.println(totals);
[15000000.0, 450000.0, 800000.0, 3500000.0, 14000000.0]
Note: Don’t confuse
mapwithforEach.mapreturns a new stream with no side effects;forEachis used to execute an action (printing, saving to DB, etc.) and returns nothing.
5. reduce: combining multiple elements into one value
reduce aggregates the entire stream into a single value, based on an identity (starting value) and a BinaryOperator.
double totalRevenue = OrderData.sample().stream()
.filter(o -> o.status().equals("COMPLETED"))
.map(Order::total)
.reduce(0.0, Double::sum);
System.out.println(totalRevenue);
0.0is the identity – the starting value when the stream is empty.Double::sumis the accumulator, cumulatively adding each value to the result.
1.8950000E7
5.1. reduce without identity
If you don’t provide an identity, reduce returns Optional<T> because the stream might be empty.
Optional<Order> mostExpensive = OrderData.sample().stream()
.reduce((o1, o2) -> o1.total() > o2.total() ? o1 : o2);
mostExpensive.ifPresent(o -> System.out.println(o.id() + ": " + o.total()));
O1: 1.5E7
Note: For finding max/min, Java provides built-in
Stream.max(Comparator)andStream.min(Comparator)– shorter and clearer thanreduce. Usereduceonly when the aggregation logic is more complex than simple comparison.
6. collect: gathering results into data structures
collect is more flexible than toList(), allowing you to gather results into a Map, Set, or perform groupBy/partition operations.
6.1. Collecting into a Map
import java.util.Map;
import java.util.stream.Collectors;
Map<String, Double> totalByOrderId = OrderData.sample().stream()
.collect(Collectors.toMap(Order::id, Order::total));
System.out.println(totalByOrderId);
Collectors.toMaptakes two functions: a key extractor and a value extractor.- If duplicate keys exist,
toMapthrowsIllegalStateException– you need to provide a merge function if data might have duplicate keys.
{O1=1.5E7, O2=450000.0, O3=800000.0, O4=3500000.0, O5=1.4E7}
6.2. groupingBy – grouping by condition
Map<String, List<Order>> ordersByStatus = OrderData.sample().stream()
.collect(Collectors.groupingBy(Order::status));
ordersByStatus.forEach((status, orders) ->
System.out.println(status + ": " + orders.size() + " order(s)"));
COMPLETED: 3 order(s)
CANCELLED: 1 order(s)
PENDING: 1 order(s)
6.3. groupingBy combined with downstream collector
You can calculate total revenue by each status group without needing to group first and then loop again.
Map<String, Double> revenueByStatus = OrderData.sample().stream()
.collect(Collectors.groupingBy(
Order::status,
Collectors.summingDouble(Order::total)
));
System.out.println(revenueByStatus);
{COMPLETED=18950000.0, CANCELLED=800000.0, PENDING=14000000.0}
groupingBy with downstream collectors (summingDouble, counting, averagingDouble, etc.) helps you avoid writing reduce manually for each group.
7. Combining all four operations in one pipeline
Here’s a real-world example: get a list of product names from completed orders with value > 1 million, sorted in descending order by total amount.
List<String> topProducts = OrderData.sample().stream()
.filter(o -> o.status().equals("COMPLETED"))
.filter(o -> o.total() > 1_000_000)
.sorted(Comparator.comparingDouble(Order::total).reversed())
.map(Order::product)
.toList();
System.out.println(topProducts);
[Laptop, Monitor]
The order of operations here is important: filter first to reduce the number of elements to process, sorted after filtering to avoid unnecessary sorting, map at the end to only extract the needed field.
8. Writing tests for pipelines
Always write tests for business logic in stream pipelines; don’t leave it “hidden” in a long line of code without verification.
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.26.3</version>
<scope>test</scope>
</dependency>
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class OrderAnalyticsTest {
@Test
void shouldCalculateTotalRevenueForCompletedOrders() {
double totalRevenue = OrderData.sample().stream()
.filter(o -> o.status().equals("COMPLETED"))
.map(Order::total)
.reduce(0.0, Double::sum);
assertThat(totalRevenue).isEqualTo(18_950_000.0);
}
@Test
void shouldGroupOrdersByStatus() {
var grouped = OrderData.sample().stream()
.collect(java.util.stream.Collectors.groupingBy(Order::status));
assertThat(grouped)
.containsKey("COMPLETED")
.extractingByKey("COMPLETED", org.assertj.core.api.InstanceOfAssertFactories.list(Order.class))
.hasSize(3);
}
}
The first test verifies that the reduce result correctly calculates the total revenue. The second test checks that groupingBy creates the correct number of elements in the COMPLETED group.
9. Common mistakes
- Using streams when loops are clearer: if the logic has many side effects (logging, updating external variables, throwing exceptions with complex conditions), traditional
forloops are more readable than streams. - Reusing a consumed stream: a stream can only be used once. Calling
collect()orforEach()twice on the same stream throwsIllegalStateException: stream has already been operated upon or closed. - Forgetting the right identity in reduce: using the wrong identity (e.g.,
1instead of0for sum) produces wrong results but doesn’t raise an error. - toMap with duplicate keys: as mentioned in section 6.1, you need to add a merge function:
Collectors.toMap(keyFn, valueFn, (v1, v2) -> v1). - Side effects in map: avoid changing external state inside functions passed to
map/filter. Streams can run in parallel, and side effects can cause hard-to-debug race conditions.
Note: If your pipeline has more than 4-5 operations chained together and is hard to read, extract them into separate methods with clear names (
filterCompleted,calculateRevenue, etc.) instead of cramming everything into one long chain.
10. Conclusion
filter, map, reduce, and collect are the foundational toolkit for processing data in a functional style in Java. filter helps you select, map transforms, reduce aggregates into a single value, and collect flexibly gathers results into more complex structures like Map or groups by condition. Master the order of operations and avoid the common mistakes mentioned above, and you’ll write concise, clear, and easily testable data processing pipelines.