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.
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")))); }
// 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); } } }
Streams don't naturally provide access to indices. If you need to know the position of elements, loops are your friend.
// 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()); }
// 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.
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.
When you need to work with multiple collections simultaneously, nested loops are often clearer than complex stream operations.
// 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()); }
// 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.
Sometimes the "functional" approach makes simple things unnecessarily complex.
// 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()); }
// 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.
Sometimes when you have complex processing involved, it can be easier to debug with loops.
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()); }
// 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.
Streams and checked exceptions don't play well together. Loops handle exceptions naturally.
// 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()); }
// 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.
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!
Master Java Streams and Collectors with this practical guide. Learn how to transform, filter, and process data efficiently through real-world examples, from basic operations to complex data manipulations.
Explore the top 5 features released from Java 21 to Java 23, including Virtual Threads, Pattern Matching with Records and Sealed Classes, Structured Concurrency, Scoped Values, and Stream Gatherers. Learn how these features can enhance your Java applications.
Get instant AI-powered summaries of YouTube videos and websites. Save time while enhancing your learning experience.