Spring Boot 6 min read

Java Streams API: From Basics to Advanced Patterns

Master Java Streams for clean, efficient data processing. Map, filter, reduce, collectors, parallel streams, and real-world patterns.

MR

Moshiour Rahman

· Updated May 9, 2026

The Problem: Loops Are Cluttering Your Code

You need to filter active users, map to DTOs, and collect to a list:

// Before Streams - imperative, verbose
List<UserDto> activeUsers = new ArrayList<>();
for (User user : users) {
    if (user.isActive()) {
        UserDto dto = new UserDto(user.getId(), user.getName());
        activeUsers.add(dto);
    }
}
// With Streams - declarative, clean
List<UserDto> activeUsers = users.stream()
    .filter(User::isActive)
    .map(u -> new UserDto(u.getId(), u.getName()))
    .toList();

Streams make data transformations readable and maintainable.

Quick Answer (TL;DR)

// Filter + Map + Collect
List<String> names = users.stream()
    .filter(u -> u.getAge() > 18)
    .map(User::getName)
    .toList();

// Group by
Map<Department, List<User>> byDept = users.stream()
    .collect(Collectors.groupingBy(User::getDepartment));

// Reduce
int totalAge = users.stream()
    .mapToInt(User::getAge)
    .sum();

// Find
Optional<User> admin = users.stream()
    .filter(u -> u.getRole() == Role.ADMIN)
    .findFirst();

Stream Pipeline

Every stream follows the same three-stage pattern: source, intermediate operations, and a terminal operation that triggers execution.

Java Stream Pipeline

Key insight: Streams are lazy - intermediate operations like filter() and map() don’t execute until a terminal operation (collect(), forEach()) triggers the pipeline.


Core Operations

filter() - Keep Matching Elements

// Keep active users over 18
List<User> result = users.stream()
    .filter(User::isActive)
    .filter(u -> u.getAge() >= 18)
    .toList();

// Multiple conditions in one filter
List<User> result = users.stream()
    .filter(u -> u.isActive() && u.getAge() >= 18)
    .toList();

map() - Transform Elements

// Extract names
List<String> names = users.stream()
    .map(User::getName)
    .toList();

// Transform to DTO
List<UserDto> dtos = users.stream()
    .map(u -> new UserDto(u.getId(), u.getName(), u.getEmail()))
    .toList();

// Chain maps
List<String> upperNames = users.stream()
    .map(User::getName)
    .map(String::toUpperCase)
    .toList();

flatMap() - Flatten Nested Collections

// Users have List<Order> orders
// Get all orders from all users
List<Order> allOrders = users.stream()
    .flatMap(u -> u.getOrders().stream())
    .toList();

// Get all unique products ordered
Set<String> products = users.stream()
    .flatMap(u -> u.getOrders().stream())
    .flatMap(o -> o.getItems().stream())
    .map(Item::getProductId)
    .collect(Collectors.toSet());

sorted() - Order Elements

// Natural order
List<String> sorted = names.stream()
    .sorted()
    .toList();

// Custom comparator
List<User> byAge = users.stream()
    .sorted(Comparator.comparingInt(User::getAge))
    .toList();

// Multiple fields
List<User> sorted = users.stream()
    .sorted(Comparator
        .comparing(User::getDepartment)
        .thenComparing(User::getName)
        .thenComparingInt(User::getAge).reversed())
    .toList();

distinct() - Remove Duplicates

// Unique names
List<String> uniqueNames = users.stream()
    .map(User::getDepartment)
    .distinct()
    .toList();

limit() and skip() - Pagination

// First 10
List<User> firstPage = users.stream()
    .limit(10)
    .toList();

// Skip first 10, take next 10 (page 2)
List<User> secondPage = users.stream()
    .skip(10)
    .limit(10)
    .toList();

Collectors - Gathering Results

toList(), toSet(), toMap()

List<String> list = stream.collect(Collectors.toList());
List<String> list = stream.toList();  // Java 16+ immutable

Set<String> set = stream.collect(Collectors.toSet());

Map<Long, User> byId = users.stream()
    .collect(Collectors.toMap(User::getId, Function.identity()));

// Handle duplicate keys
Map<String, User> byEmail = users.stream()
    .collect(Collectors.toMap(
        User::getEmail,
        Function.identity(),
        (existing, replacement) -> existing  // Keep first
    ));

groupingBy() - Group Elements

// Group by department
Map<Department, List<User>> byDept = users.stream()
    .collect(Collectors.groupingBy(User::getDepartment));

// Group and count
Map<Department, Long> countByDept = users.stream()
    .collect(Collectors.groupingBy(
        User::getDepartment,
        Collectors.counting()
    ));

// Group and sum
Map<Department, Integer> salaryByDept = users.stream()
    .collect(Collectors.groupingBy(
        User::getDepartment,
        Collectors.summingInt(User::getSalary)
    ));

// Group and map values
Map<Department, List<String>> namesByDept = users.stream()
    .collect(Collectors.groupingBy(
        User::getDepartment,
        Collectors.mapping(User::getName, Collectors.toList())
    ));

// Nested grouping
Map<Department, Map<Role, List<User>>> nested = users.stream()
    .collect(Collectors.groupingBy(
        User::getDepartment,
        Collectors.groupingBy(User::getRole)
    ));

partitioningBy() - Split into Two Groups

// Active vs inactive
Map<Boolean, List<User>> partitioned = users.stream()
    .collect(Collectors.partitioningBy(User::isActive));

List<User> active = partitioned.get(true);
List<User> inactive = partitioned.get(false);

joining() - Concatenate Strings

String names = users.stream()
    .map(User::getName)
    .collect(Collectors.joining(", "));
// "Alice, Bob, Charlie"

String csv = users.stream()
    .map(User::getName)
    .collect(Collectors.joining(",", "[", "]"));
// "[Alice,Bob,Charlie]"

Statistics

IntSummaryStatistics stats = users.stream()
    .collect(Collectors.summarizingInt(User::getAge));

stats.getCount();    // 100
stats.getSum();      // 3500
stats.getMin();      // 18
stats.getMax();      // 65
stats.getAverage(); // 35.0

Terminal Operations

reduce() - Combine Elements

// Sum
int total = numbers.stream()
    .reduce(0, Integer::sum);

// Max
Optional<Integer> max = numbers.stream()
    .reduce(Integer::max);

// Custom reduction
String combined = strings.stream()
    .reduce("", (a, b) -> a + b);

findFirst() and findAny()

// First matching
Optional<User> firstAdmin = users.stream()
    .filter(u -> u.getRole() == Role.ADMIN)
    .findFirst();

// Any matching (better for parallel)
Optional<User> anyAdmin = users.parallelStream()
    .filter(u -> u.getRole() == Role.ADMIN)
    .findAny();

anyMatch(), allMatch(), noneMatch()

boolean hasAdmin = users.stream()
    .anyMatch(u -> u.getRole() == Role.ADMIN);

boolean allActive = users.stream()
    .allMatch(User::isActive);

boolean noMinors = users.stream()
    .noneMatch(u -> u.getAge() < 18);

count(), min(), max()

long count = users.stream()
    .filter(User::isActive)
    .count();

Optional<User> oldest = users.stream()
    .max(Comparator.comparingInt(User::getAge));

Optional<User> youngest = users.stream()
    .min(Comparator.comparingInt(User::getAge));

Primitive Streams

// IntStream, LongStream, DoubleStream - avoid boxing
int sum = users.stream()
    .mapToInt(User::getAge)
    .sum();

double avg = users.stream()
    .mapToInt(User::getSalary)
    .average()
    .orElse(0);

// Range
IntStream.range(0, 10).forEach(System.out::println);      // 0-9
IntStream.rangeClosed(1, 10).forEach(System.out::println); // 1-10

// Generate
IntStream.generate(() -> random.nextInt(100))
    .limit(10)
    .toArray();

Real-World Patterns

DTO Transformation

public List<OrderSummaryDto> getOrderSummaries(Long userId) {
    return orderRepository.findByUserId(userId).stream()
        .filter(o -> o.getStatus() != OrderStatus.CANCELLED)
        .sorted(Comparator.comparing(Order::getCreatedAt).reversed())
        .map(o -> new OrderSummaryDto(
            o.getId(),
            o.getTotalAmount(),
            o.getStatus(),
            o.getCreatedAt()
        ))
        .toList();
}

Aggregation Report

public DepartmentReport generateReport() {
    Map<Department, DoubleSummaryStatistics> salaryStats = employees.stream()
        .collect(Collectors.groupingBy(
            Employee::getDepartment,
            Collectors.summarizingDouble(Employee::getSalary)
        ));

    return salaryStats.entrySet().stream()
        .map(e -> new DeptStats(
            e.getKey(),
            e.getValue().getCount(),
            e.getValue().getAverage(),
            e.getValue().getSum()
        ))
        .sorted(Comparator.comparing(DeptStats::totalSalary).reversed())
        .toList();
}

Null-Safe Processing

// Filter nulls
List<String> names = users.stream()
    .map(User::getName)
    .filter(Objects::nonNull)
    .toList();

// Optional chain
Optional<String> managerName = Optional.ofNullable(employee)
    .map(Employee::getDepartment)
    .map(Department::getManager)
    .map(Manager::getName);

Batching

public <T> List<List<T>> batch(List<T> items, int batchSize) {
    return IntStream.range(0, (items.size() + batchSize - 1) / batchSize)
        .mapToObj(i -> items.subList(
            i * batchSize,
            Math.min((i + 1) * batchSize, items.size())
        ))
        .toList();
}

Parallel Streams

// Parallel processing
long count = users.parallelStream()
    .filter(this::expensiveCheck)
    .count();

// When to use parallel:
// ✓ Large datasets (>10,000 elements)
// ✓ CPU-intensive operations
// ✓ No shared mutable state
// ✗ Small datasets (overhead > benefit)
// ✗ I/O operations (use virtual threads instead)
// ✗ Order matters (unless you sort after)

Common Pitfalls

PitfallProblemFix
Reusing streamsIllegalStateExceptionCreate new stream
Side effects in mapUnpredictableUse forEach for side effects
Null in streamNullPointerExceptionFilter nulls first
Parallel + orderResults scrambledUse sequential or sort after
Infinite streamsOOMAlways use limit()
// BAD - reusing stream
var stream = users.stream();
stream.filter(User::isActive).count();
stream.map(User::getName).toList();  // IllegalStateException!

// BAD - side effect in map
List<User> processed = new ArrayList<>();
users.stream()
    .map(u -> { processed.add(u); return u; })  // Don't!
    .toList();

// GOOD - use forEach for side effects
users.stream()
    .filter(User::isActive)
    .forEach(processed::add);

Code Repository

Complete stream examples:

GitHub: techyowls/techyowls-io-blog-public/java-streams-guide


Further Reading


Write clean data transformations. Follow TechyOwls for more practical guides.

Is your AI API down right now? Check live status for OpenAI, Claude, Gemini, Groq, and more.
Check status →
MR

Moshiour Rahman

Software Architect & AI Engineer

Share:
MR

Moshiour Rahman

Software Architect & AI Engineer

Enterprise software architect with deep expertise in financial systems, distributed architecture, and AI-powered applications. Building large-scale systems at Fortune 500 companies. Specializing in LLM orchestration, multi-agent systems, and cloud-native solutions. I share battle-tested patterns from real enterprise projects.

Related Articles

Comments

Comments are powered by GitHub Discussions.

Configure Giscus at giscus.app to enable comments.