When should we not use Java Streams?

    When should we not use Java Streams?

    03/08/2025

    Introduction

    Java Streams are fantastic for functional programming and data processing. But there are times when good old-fashioned loops are simply better. Let me show you few scenarios where you should think twice before reaching for streams.

    1. When You Need to Mutate Data

    Streams are ideally suited for scenarios where you need to process data in a functional way where you don't need to mutate the original data. Although you can mutate the original data using streams, it can cause some unexpected side effects especially when you are using parallel streams.

    public void updateProductPrices(List<Product> products) { products.stream() .filter(product -> product.getCategory().equals("Electronics")) .forEach(product -> product.setPrice(product.getPrice().multiply(new BigDecimal("0.9")))); }

    The Solution with Loops

    // This works perfectly - modifies the original objects public void updateProductPrices(List<Product> products) { for (Product product : products) { if (product.getCategory().equals("Electronics")) { BigDecimal newPrice = product.getPrice().multiply(new BigDecimal("0.9")); product.setPrice(newPrice); } } }

    2. When You Need Index Access

    Streams don't naturally provide access to indices. If you need to know the position of elements, loops are your friend.

    The Problem with Streams

    // Not straight forward with streams public List<String> addIndices(List<String> items) { AtomicInteger index = new AtomicInteger(0); return items.stream() .map(item -> index.getAndIncrement() + ": " + item) .collect(Collectors.toList()); }

    The Solution with Loops

    // Clean and straightforward with loops public List<String> addIndices(List<String> items) { List<String> result = new ArrayList<>(); for (int i = 0; i < items.size(); i++) { result.add(i + ": " + items.get(i)); } return result; }

    When to use loops: When you need to know the position, compare adjacent elements, or access elements by index.

    3. Performance-Critical Code

    Streams might have a slight overhead in performance-critical scenarios because of the abstraction they provide. Simple loops are often faster in such scenarios. It is not always huge difference, but it is something to consider if you are in a performance critical section of your code. Ideally you can test both approaches and see which one is faster.

    4. Process Multiple Collections Together in Nested Loops

    When you need to work with multiple collections simultaneously, nested loops are often clearer than complex stream operations.

    The Problem with Streams

    // Hard to read public List<Pair<String, Integer>> combineLists(List<String> names, List<Integer> scores) { if (names.size() != scores.size()) { throw new IllegalArgumentException("Lists must have the same size"); } return IntStream.range(0, names.size()) .mapToObj(i -> new Pair<>(names.get(i), scores.get(i))) .collect(Collectors.toList()); }

    The Solution with Loops

    // Clear and easy to understand public List<Pair<String, Integer>> combineLists(List<String> names, List<Integer> scores) { if (names.size() != scores.size()) { throw new IllegalArgumentException("Lists must have the same size"); } List<Pair<String, Integer>> result = new ArrayList<>(); for (int i = 0; i < names.size(); i++) { result.add(new Pair<>(names.get(i), scores.get(i))); } return result; }

    When to use loops: When working with multiple collections, generating combinations, or complex nested iterations.

    5. When Stream based Code Becomes Unreadable and Imperative Code is More Readable

    Sometimes the "functional" approach makes simple things unnecessarily complex.

    The Problem with Streams

    // Overly complex for a simple operation public List<String> getActiveUserNames(List<User> users) { return users.stream() .filter(user -> user.getStatus() == UserStatus.ACTIVE) .map(User::getName) .filter(name -> name != null && !name.trim().isEmpty()) .map(String::trim) .distinct() .sorted() .collect(Collectors.toList()); }

    The Solution with Loops

    // Much more readable public List<String> getActiveUserNames(List<User> users) { Set<String> uniqueNames = new TreeSet<>(); for (User user : users) { if (user.getStatus() == UserStatus.ACTIVE) { String name = user.getName(); if (name != null && !name.trim().isEmpty()) { uniqueNames.add(name.trim()); } } } return new ArrayList<>(uniqueNames); }

    When to use loops: When the stream version becomes harder to read than a simple loop, or when you have complex conditional logic.

    6. Easier Debugging when complex processing is involved

    Sometimes when you have complex processing involved, it can be easier to debug with loops.

    The Problem with Streams

    public List<String> processItems(List<Item> items) { return items.stream() .filter(item -> item.getStatus() == Status.ACTIVE) .map(item -> processItem(item)) .filter(result -> result != null) .collect(Collectors.toList()); }

    The Solution with Loops

    // Easy to debug - you can set breakpoints anywhere public List<String> processItems(List<Item> items) { List<String> results = new ArrayList<>(); for (Item item : items) { if (item.getStatus() == Status.ACTIVE) { // You can set a breakpoint here and inspect 'item' String result = processItem(item); if (result != null) { results.add(result); } } } return results; }

    When to use loops: When you anticipate needing to debug the logic, inspect intermediate values, or understand the flow step by step.

    7. Exception Handling

    Streams and checked exceptions don't play well together. Loops handle exceptions naturally.

    The Problem with Streams

    // Need to catch checked exceptions public List<String> processFiles(List<File> files) { return files.stream() .map(file -> { try { return readFile(file); } catch (IOException e) { throw new UncheckedIOException(e); } }) .collect(Collectors.toList()); }

    The Solution with Loops

    // No need to catch checked exceptions public List<String> processFiles(List<File> files) throws IOException { List<String> contents = new ArrayList<>(); for (File file : files) { contents.add(readFile(file)); // Clean exception propagation } return contents; }

    When to use loops: When dealing with checked exceptions, custom exception handling, or when you need fine-grained control over error handling.

    Conclusion

    Although streams are powerful, they are not always the best tool for the job. It is important to understand the trade-offs and choose the right tool for the job.

    To stay updated with the latest updates in Java and Spring follow us on youtube, linked in and medium.

    Happy coding!

    Summarise

    Transform Your Learning

    Get instant AI-powered summaries of YouTube videos and websites. Save time while enhancing your learning experience.

    Instant video summaries
    Smart insights extraction
    Channel tracking