Iterating Collections — for-each, Iterator, Streams

8 min read

You've seen the for-each loop walk arrays, ArrayLists, Sets and Maps. Java has three more iteration tools, each suited to a slightly different job. Iterator is the explicit walker you reach for when you need to remove elements while iterating. forEach with a lambda is the tightest one-liner for "do something to every element." The Stream API chains filter, map, and collect into a fluent pipeline — exactly like JavaScript's array.filter(...).map(...) but typed and explicit. This lesson shows when each tool earns its place and warns about the runtime exception that catches almost every Java beginner once.

The four iteration tools, briefly

// 1. Enhanced for loop — the simple, default case
for (String name : tests) { ... }
 
// 2. Iterator — explicit walker, supports safe removal
Iterator<String> it = tests.iterator();
while (it.hasNext()) { ... it.remove(); }
 
// 3. forEach + lambda — one-liner action
tests.forEach(t -> System.out.println(t));
 
// 4. Stream API — pipeline of filter / map / collect
List<String> failures = results.stream()
    .filter(r -> !r.passed)
    .map(r -> r.name)
    .toList();

for-each is the right default for "do something with each element." Use the others when you have a specific reason: removal during iteration (Iterator), the most concise possible action (forEach), or transformation into a new collection (Stream).

ConcurrentModificationException — the trap that catches everyone

Try to mutate a collection while a for-each loop walks it and Java throws this at runtime:

import java.util.ArrayList;
import java.util.List;
 
public class BrokenRemoval {
    public static void main(String[] args) {
        List<String> tests = new ArrayList<>(List.of("login", "SKIP_legacy", "checkout", "SKIP_export"));
 
        for (String t : tests) {
            if (t.startsWith("SKIP_")) {
                tests.remove(t);     // ❌ throws ConcurrentModificationException at runtime
            }
        }
    }
}

Output:

Exception in thread "main" java.util.ConcurrentModificationException
    at java.base/java.util.ArrayList$Itr.checkForComodification(...)

The for-each loop is implemented internally with an Iterator. Mutating the underlying list invalidates the iterator, and the next it.next() call detects the inconsistency and throws. The point isn't that the JVM is being fussy; it's that mid-iteration mutation often leaves callers reading the wrong indices, and Java would rather fail loudly than silently skip elements.

Three correct fixes — pick the one that fits.

Fix 1 — explicit Iterator.remove()

The Iterator knows where it is, so it can remove the current element safely:

import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
 
public class IteratorDemo {
    public static void main(String[] args) {
        List<String> tests = new ArrayList<>(List.of("login", "SKIP_legacy", "checkout", "SKIP_export"));
 
        Iterator<String> it = tests.iterator();
        while (it.hasNext()) {
            String t = it.next();
            if (t.startsWith("SKIP_")) {
                it.remove();        // ✅ safe — Iterator's own remove
            }
        }
 
        System.out.println(tests);
    }
}

Output:

[login, checkout]

it.remove() removes the element returned by the most recent it.next(). It's the only method on the iterator that can mutate the collection without triggering the exception. This is the canonical way to filter a list in place.

Fix 2 — removeIf with a predicate (modern, terse)

For Collections (which means every list and set), removeIf takes a lambda and removes matching elements in one call:

List<String> tests = new ArrayList<>(List.of("login", "SKIP_legacy", "checkout", "SKIP_export"));
tests.removeIf(t -> t.startsWith("SKIP_"));
System.out.println(tests);   // [login, checkout]

This is the cleanest Java has. Internally it does Fix 1 for you. Use it whenever the rule is a simple predicate.

Fix 3 — build a new list instead of mutating

Rather than removing from the original, create a new list of survivors. This is what Stream pipelines do; it's also the safest pattern under concurrency:

List<String> filtered = new ArrayList<>();
for (String t : tests) {
    if (!t.startsWith("SKIP_")) filtered.add(t);
}

Or with a Stream (lesson preview — chapter 8 covers Streams in depth):

List<String> filtered = tests.stream()
    .filter(t -> !t.startsWith("SKIP_"))
    .toList();

Both produce a new list. The original tests is untouched. Mutating in place is fine; building a new list is fine; the only thing that's not fine is mutating the original mid-walk through a for-each.

forEach + lambda — the one-liner

Every Collection has a forEach method that takes a lambda:

List<String> tests = List.of("login", "search", "checkout");
 
tests.forEach(t -> System.out.println("Running: " + t));
tests.forEach(System.out::println);     // method reference — even shorter

Output:

Running: login
Running: search
Running: checkout
login
search
checkout

System.out::println is a method reference — the lambda t -> System.out.println(t) written shorter. We'll meet method references properly in chapter 8.

forEach is purely for side effects (printing, logging, mutating something outside the lambda). For transformations, use a Stream.

Stream API — preview

Streams are lesson 4 of chapter 8 in full. The preview, because you'll use them constantly:

import java.util.List;
 
public class FailureNames {
    record Result(String name, boolean passed) {}
 
    public static void main(String[] args) {
        List<Result> results = List.of(
            new Result("Login",    true),
            new Result("Search",   true),
            new Result("Checkout", false),
            new Result("Logout",   true),
            new Result("Export",   false)
        );
 
        List<String> failures = results.stream()
            .filter(r -> !r.passed())
            .map(Result::name)
            .toList();
 
        long failureCount = results.stream()
            .filter(r -> !r.passed())
            .count();
 
        System.out.println("Failures: " + failures);
        System.out.println("Count:    " + failureCount);
    }
}

Output:

Failures: [Checkout, Export]
Count:    2

Read the pipeline left to right: take the list as a stream, keep only the failures, project each one to its name, collect into a List<String>. This is roughly equivalent to JavaScript's results.filter(r => !r.passed).map(r => r.name). Streams shine for transformations — derive a new collection from an old one. They're not a replacement for plain loops; they're the right tool when you're producing data, not just printing it.

When to use which

Four iteration tools — when each fits

for-each

  • Default for 'walk and do something'

  • Reads cleanest; works for arrays, Lists, Sets, Maps

  • ❌ Cannot mutate the collection inside the loop

  • Use unless one of the other tools is clearly better

Iterator + .remove()

  • Use when you need to remove elements while iterating

  • Slightly verbose: hasNext() / next() / remove()

  • Only safe-remove path on legacy code or complex conditions

  • For a single predicate, prefer removeIf instead

forEach + lambda

  • Tightest syntax for side-effects only

  • list.forEach(System.out::println)

  • Don't use for transformations — that's what Streams are for

  • Same effect as a for-each loop, fewer characters

Stream API

  • Use when you're filtering, mapping, or grouping into a new collection

  • .stream().filter(...).map(...).toList()

  • Reads top-down as a pipeline of operations

  • Covered in depth in chapter 8

The cheat sheet: for-each by default, Iterator/removeIf for in-place removal, forEach+lambda for tight one-liner side effects, Stream for transformations. Mixing them within a single method is fine and common.

Iterating a Map — three views

Refresher from lesson 2 of this chapter: a Map exposes three views you can iterate via for-each:

Map<String, String> headers = new LinkedHashMap<>();
headers.put("Accept", "application/json");
headers.put("User-Agent", "qa-suite/1.0");
 
for (Map.Entry<String, String> e : headers.entrySet()) {
    System.out.println(e.getKey() + ": " + e.getValue());
}
 
headers.forEach((k, v) -> System.out.println(k + " -> " + v));   // BiConsumer lambda

forEach on a Map takes a two-argument lambda — (key, value) -> .... Often the most concise way to print a map.

⚠️ Common mistakes

  • tests.remove(...) inside a for-each. Throws ConcurrentModificationException. Use removeIf, an explicit Iterator, or build a new list. Once you know what the exception means, the fix is mechanical.
  • Treating forEach as a replacement for Streams. list.forEach(...) only does side effects — there's no return value, no transformation. If you need a new collection, use .stream().filter(...).map(...).toList().
  • new ArrayList<>(stream.toList()) to "make it mutable." Stream.toList() returns an unmodifiable list. If you'll mutate the result, collect explicitly: .collect(Collectors.toList()) or pass the immutable list to new ArrayList<>(...) — the second wraps it in a mutable copy. Don't try to mutate the result of toList() directly; you'll get UnsupportedOperationException.

🎯 Practice task

Filter a list of test results four different ways. 25-30 minutes.

  1. Create IterationDemo.java. import java.util.ArrayList;, import java.util.Iterator;, import java.util.List;.
  2. Declare a small static class Result with String name and boolean passed. Build List<Result> results = new ArrayList<>(...) with at least 5 entries — mix passes and failures.
  3. Approach A — explicit Iterator + remove: copy the list (new ArrayList<>(results)), walk with an Iterator, and it.remove() every passing result. Print what's left.
  4. Approach B — removeIf: copy the list again and call copy.removeIf(r -> r.passed);. Print.
  5. Approach C — build a new list with for-each: declare List<Result> failures = new ArrayList<>();, loop with for-each, if (!r.passed) failures.add(r);. Print.
  6. Approach D — Stream pipeline: List<String> failureNames = results.stream().filter(r -> !r.passed).map(r -> r.name).toList();. Print.
  7. Confirm all four approaches give the same set of failures. Notice how each is more concise than the previous, and each makes the same trade-offs visible.
  8. Stretch: deliberately reproduce the bug — mutate results inside a for-each (if (!r.passed) results.remove(r);). Run and read the ConcurrentModificationException stack trace. Then fix it with removeIf and confirm the test passes. Hitting the exception once and fixing it cements the rule for life.

That closes Chapter 6 — the four collection families and the four iteration patterns are enough to model almost any test data shape. Chapter 7 takes the next step: handling errors gracefully and reading and writing files for fixtures, configs, and reports.

// tip to track lessons you complete and pick up where you left off across devices.