Iterator Design Pattern in Java
The iterator design pattern in Java gives clients a standard way to walk through the elements of a collection one by one without seeing how the collection stores them. The collection hands out an iterator object with hasNext() and next(), and the client loops with it, whether the data lives in an array, a tree or a database table.
It matters because exposing internal structure is fragile. If callers loop over a raw array, they must know its size field and its empty slots, and every change to the storage breaks them. Java builds this pattern into the language through Iterable, Iterator and the for-each loop, so knowing how to write good iterators pays off daily. It is a behavioral design pattern.
1. What Is the Iterator Design Pattern?
The GoF intent is: "Provide a way to access the elements of an aggregate object sequentially without exposing its underlying representation."
The pattern separates two jobs: storing elements, which is the collection's job, and traversing them, which is the iterator's job. The collection, called the aggregate, creates an iterator. The iterator keeps its own position, a cursor, and answers two questions: is there another element, and what is it?
Because each iterator has its own cursor, several traversals can run at once, and one collection can offer several orders, such as all elements, only pending ones, depth-first or breadth-first. In Java, any class implementing Iterable<T> works in a for-each loop. The benefit is encapsulation: storage can change from an array to a tree to a database without touching any caller.
2. Key Components
| Component | Responsibility | In the Basic Example |
|---|---|---|
| Iterator | Interface for traversal: hasNext(), next() | java.util.Iterator<Stop> |
| Concrete Iterator | Tracks the position and knows how to reach the next element | RouteIterator |
| Aggregate | Interface that creates iterators | java.lang.Iterable<Stop> |
| Concrete Aggregate | Stores the elements and returns concrete iterators | DeliveryRoute |
| Client | Loops using only the iterator interface | The for-each loops in main |
The client asks the aggregate for an iterator, then calls hasNext() and next() until it is done. The concrete iterator is usually an inner class, so it can read the aggregate's private fields while the client cannot.
3. When Is Iterator Useful?
- Custom collections: a delivery route stored in an array that callers should loop over without touching the array.
- Non-linear structures: a product category tree walked depth-first for a menu or breadth-first for a level view.
- Data too large for memory: years of bank transactions read batch by batch from a database while the caller sees one simple sequence.
4. UML Class Diagram
The diagram shows the Basic Example.
Iterable and Iterator; DeliveryRoute creates its own RouteIterator, which reads the private array.IterableandIteratorare the JDK interfaces that play the GoF Aggregate and Iterator roles.DeliveryRouteimplementsIterable, so it works in a for-each loop.RouteIteratorimplementsIteratorand is a private inner class: clients never see its type, only the interface.- The route creates the iterator (
«create»); the iterator remembersmodCountso it can detect changes.
5. Step-by-Step Execution Flow
- The client writes
for (Stop stop : route). The compiler turns this into a call toroute.iterator(). - The route creates a
RouteIteratorwith its cursor at 0 and a copy of the currentmodCount. - The loop calls
hasNext(). The iterator checks for changes, skips elements that do not match its filter, and returnstrueif one remains. - The loop calls
next(), which returns the element and advances the cursor. - If the route is modified mid-loop,
modCountno longer matches, and the next call throwsConcurrentModificationException.
6. Real-World Use Cases
- E-commerce: walking category trees, cart lines and search results without exposing their storage.
- Payments and banking: streaming transactions for statements, reconciliation and fraud scans, batch by batch.
- Notifications: iterating over recipients drawn from several sources as one sequence.
- The JDK: every collection's
iterator();ArrayListandHashMapiterators are fail-fast,ConcurrentHashMapiterators are weakly consistent, andCopyOnWriteArrayListiterators read a snapshot.Spliterator(Java 8+) extends the idea for streams and parallel splitting, and Java 21'sSequencedCollection.reversed()offers reverse traversal. - Spring and JDBC: a JDBC
ResultSetis a database cursor; Spring Data repository methods can return aStream<T>backed by such a cursor, and Spring Data 3.1+ supports keyset scrolling withWindowandScrollPosition.
7. Problem Without the Pattern
A delivery route that exposes its array:
import java.util.Arrays;
public class RouteWithoutIterator {
record Stop(String address, boolean delivered) { }
static final class DeliveryRoute {
Stop[] stops = new Stop[4]; // exposed so callers can loop over it
int size = 0;
void add(Stop stop) {
if (size == stops.length) stops = Arrays.copyOf(stops, size * 2);
stops[size++] = stop;
}
}
public static void main(String[] args) {
DeliveryRoute route = new DeliveryRoute();
route.add(new Stop("12 MG Road", true));
route.add(new Stop("4 Park Street", false));
route.add(new Stop("88 Lake View", false));
// Every caller must know the internal array, the size field and the null slots
int pending = 0;
for (int i = 0; i < route.size; i++) {
if (!route.stops[i].delivered()) pending++;
}
System.out.println("Pending stops: " + pending);
// Another caller forgets the size field and walks into empty slots
try {
for (Stop stop : route.stops) {
System.out.println(stop.address());
}
} catch (NullPointerException e) {
System.out.println("Crashed: the array has empty slots beyond 'size'");
}
}
}
Pending stops: 2
12 MG Road
4 Park Street
88 Lake View
Crashed: the array has empty slots beyond 'size'
- Broken encapsulation: callers depend on the array, the
sizefield and the growth strategy. - Easy mistakes: one caller forgot
sizeand crashed on an empty slot. - Duplicated loops: every caller rewrites the same index logic and filters.
- No protection: callers can modify the array directly.
8. Solution Using the Pattern
Make the array private, implement Iterable<Stop>, and return an iterator that knows about size. Add a second method, pendingStops(), that returns a filtered traversal over the same data.
Callers now write plain for-each loops and cannot touch the array. The empty-slot crash is gone, because only the iterator handles indexes, and the storage can change without affecting any caller. The iterator also detects modification during a loop instead of silently skipping elements.
9. Iterator Design Pattern in Java: Code Examples
Each example is a single file that runs on Java 21 with java FileName.java.
Basic Example: Delivery Route with Its Own Iterators
Problem: fix section 7 so that callers loop over stops safely, with a second filtered traversal and protection against changes mid-loop.
import java.util.Arrays;
import java.util.ConcurrentModificationException;
import java.util.Iterator;
import java.util.NoSuchElementException;
import java.util.function.Predicate;
public class DeliveryRouteDemo {
record Stop(String address, boolean delivered) { }
// Aggregate: hides its array and hands out iterators instead
static final class DeliveryRoute implements Iterable<Stop> {
private Stop[] stops = new Stop[4];
private int size = 0;
private int modCount = 0; // changes on every structural modification
void add(Stop stop) {
if (size == stops.length) stops = Arrays.copyOf(stops, size * 2);
stops[size++] = stop;
modCount++;
}
@Override
public Iterator<Stop> iterator() {
return new RouteIterator(stop -> true);
}
// A second traversal over the same data, with a filter
Iterable<Stop> pendingStops() {
return () -> new RouteIterator(stop -> !stop.delivered());
}
// Concrete iterator: an inner class, so it can read the private array
private final class RouteIterator implements Iterator<Stop> {
private final Predicate<Stop> filter;
private final int expectedModCount = modCount;
private int cursor = 0;
RouteIterator(Predicate<Stop> filter) { this.filter = filter; }
@Override
public boolean hasNext() {
checkForChanges();
while (cursor < size && !filter.test(stops[cursor])) cursor++; // skip non-matching stops
return cursor < size;
}
@Override
public Stop next() {
if (!hasNext()) throw new NoSuchElementException();
return stops[cursor++];
}
private void checkForChanges() {
if (modCount != expectedModCount) throw new ConcurrentModificationException("route changed during iteration");
}
}
}
public static void main(String[] args) {
DeliveryRoute route = new DeliveryRoute();
route.add(new Stop("12 MG Road", true));
route.add(new Stop("4 Park Street", false));
route.add(new Stop("88 Lake View", false));
route.add(new Stop("7 Hill Lane", true));
route.add(new Stop("31 Station Road", false)); // forces the array to grow
System.out.println("All stops:");
for (Stop stop : route) {
System.out.println(" " + stop.address() + (stop.delivered() ? " (done)" : ""));
}
System.out.println("Pending stops:");
for (Stop stop : route.pendingStops()) {
System.out.println(" " + stop.address());
}
try {
for (Stop stop : route) {
route.add(new Stop("Urgent: 2 Bank Street", false)); // modifying while iterating
}
} catch (ConcurrentModificationException e) {
System.out.println("Fail-fast: " + e.getMessage());
}
}
}
Important classes and methods
RouteIteratoris a private inner class: it reads the private array, and clients only seeIterator<Stop>.pendingStops()returns a lambda;Iterablehas a single abstract method, so a lambda can implement it.modCountandexpectedModCountmake the iterator fail-fast, as the JDK'sArrayListiterator does.next()throwsNoSuchElementExceptionpast the end, as theIteratorcontract requires.
Expected output
All stops:
12 MG Road (done)
4 Park Street
88 Lake View
7 Hill Lane (done)
31 Station Road
Pending stops:
4 Park Street
88 Lake View
31 Station Road
Fail-fast: route changed during iteration
How it runs
- The fifth stop makes the array grow, which callers never notice.
- The filtered iterator skips delivered stops inside
hasNext(). - Adding a stop during a loop changes
modCount, so the nexthasNext()throws.
Practical Example: Category Tree Traversal
Problem: an e-commerce site needs its category tree as an indented menu and as a level-by-level overview. The traversal must not use recursion, so very deep trees cannot overflow the stack.
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.Iterator;
import java.util.List;
import java.util.NoSuchElementException;
public class CategoryTreeIteratorDemo {
record Category(String name, List<Category> children) {
Category(String name, Category... children) { this(name, List.of(children)); }
}
record Visit(Category category, int depth) { }
// Depth-first iterator with an explicit stack: lazy, no recursion, works for any depth
static final class DepthFirstIterator implements Iterator<Visit> {
private final Deque<Visit> stack = new ArrayDeque<>();
DepthFirstIterator(Category root) { stack.push(new Visit(root, 0)); }
@Override
public boolean hasNext() { return !stack.isEmpty(); }
@Override
public Visit next() {
if (stack.isEmpty()) throw new NoSuchElementException();
Visit current = stack.pop();
List<Category> children = current.category().children();
for (int i = children.size() - 1; i >= 0; i--) { // push in reverse so the first child comes out first
stack.push(new Visit(children.get(i), current.depth() + 1));
}
return current;
}
}
// Breadth-first iterator over the same tree: same interface, different order
static final class BreadthFirstIterator implements Iterator<Visit> {
private final Deque<Visit> queue = new ArrayDeque<>();
BreadthFirstIterator(Category root) { queue.add(new Visit(root, 0)); }
@Override
public boolean hasNext() { return !queue.isEmpty(); }
@Override
public Visit next() {
if (queue.isEmpty()) throw new NoSuchElementException();
Visit current = queue.poll();
current.category().children().forEach(child -> queue.add(new Visit(child, current.depth() + 1)));
return current;
}
}
public static void main(String[] args) {
Category catalogue = new Category("All",
new Category("Electronics",
new Category("Phones", new Category("Android"), new Category("iOS")),
new Category("Laptops")),
new Category("Fashion",
new Category("Men"), new Category("Women")));
System.out.println("Menu (depth-first):");
Iterator<Visit> menu = new DepthFirstIterator(catalogue);
while (menu.hasNext()) {
Visit v = menu.next();
System.out.println(" " + " ".repeat(v.depth()) + v.category().name());
}
System.out.println("Level by level (breadth-first):");
Iterator<Visit> levels = new BreadthFirstIterator(catalogue);
StringBuilder line = new StringBuilder();
int currentDepth = 0;
while (levels.hasNext()) {
Visit v = levels.next();
if (v.depth() != currentDepth) {
System.out.println(" level " + currentDepth + ": " + line);
line.setLength(0);
currentDepth = v.depth();
}
line.append(line.isEmpty() ? "" : ", ").append(v.category().name());
}
System.out.println(" level " + currentDepth + ": " + line);
}
}
Important classes and methods
DepthFirstIteratorkeeps an explicit stack (ArrayDeque) and pushes children in reverse, so the first child is visited first.BreadthFirstIteratoruses the same deque as a queue to visit level by level.- Both are lazy: each
next()does only the work for one node, so a caller can stop early cheaply.
Expected output
Menu (depth-first):
All
Electronics
Phones
Android
iOS
Laptops
Fashion
Men
Women
Level by level (breadth-first):
level 0: All
level 1: Electronics, Fashion
level 2: Phones, Laptops, Men, Women
level 3: Android, iOS
How it runs
- Depth-first follows each branch to the bottom before moving to the next, giving the menu order.
- Breadth-first finishes each level before going deeper.
- The client code is the same
hasNext()/next()loop for both.
Real-World Enterprise Example: Batched Transaction Cursor
Problem: a statement job must scan millions of transactions without loading them all into memory, release database resources when it finishes or stops early, and work with the Stream API.
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.NavigableMap;
import java.util.NoSuchElementException;
import java.util.Spliterator;
import java.util.Spliterators;
import java.util.TreeMap;
import java.util.stream.Stream;
import java.util.stream.StreamSupport;
public class TransactionCursorDemo {
record Transaction(long id, String account, long amountInRupees) { }
// Simulated table, read with keyset pagination: "WHERE id > ? ORDER BY id LIMIT ?"
static final class TransactionRepository {
private final NavigableMap<Long, Transaction> table = new TreeMap<>();
TransactionRepository() {
long[] amounts = {-1_200, 85_000, -45_000, -300, -62_000, 15_000, -9_999, -120_000, -50, -71_500, 2_000};
for (int i = 0; i < amounts.length; i++) {
long id = 1001 + i;
table.put(id, new Transaction(id, "ACC-7", amounts[i]));
}
}
List<Transaction> fetchAfter(long afterId, int limit) {
System.out.println(" [db] SELECT ... WHERE id > " + afterId + " LIMIT " + limit);
return table.tailMap(afterId, false).values().stream().limit(limit).toList();
}
}
// Iterator over the whole table that holds only one batch in memory at a time
static final class TransactionCursor implements Iterator<Transaction>, AutoCloseable {
private final TransactionRepository repository;
private final int batchSize;
private List<Transaction> batch = List.of();
private int index = 0;
private long lastId = 0;
private boolean exhausted = false;
private boolean closed = false;
TransactionCursor(TransactionRepository repository, int batchSize) {
this.repository = repository;
this.batchSize = batchSize;
}
@Override
public boolean hasNext() {
if (closed) throw new IllegalStateException("cursor is closed");
if (index < batch.size()) return true;
if (exhausted) return false;
batch = repository.fetchAfter(lastId, batchSize); // load the next batch only when needed
index = 0;
exhausted = batch.size() < batchSize;
return !batch.isEmpty();
}
@Override
public Transaction next() {
if (!hasNext()) throw new NoSuchElementException();
Transaction t = batch.get(index++);
lastId = t.id(); // the key for the next page
return t;
}
@Override
public void close() {
if (!closed) {
closed = true;
System.out.println(" [db] cursor closed"); // a real cursor would release its connection
}
}
// Expose the cursor as a Stream; closing the stream closes the cursor
Stream<Transaction> stream() {
Spliterator<Transaction> spliterator = Spliterators.spliteratorUnknownSize(this, Spliterator.ORDERED | Spliterator.NONNULL);
return StreamSupport.stream(spliterator, false).onClose(this::close);
}
}
public static void main(String[] args) {
TransactionRepository repository = new TransactionRepository();
System.out.println("Full scan with hasNext() and next():");
long total = 0;
try (TransactionCursor cursor = new TransactionCursor(repository, 4)) {
while (cursor.hasNext()) total += cursor.next().amountInRupees();
}
System.out.println("Net movement: " + total);
System.out.println("First 3 debits above Rs. 40,000, via a Stream:");
List<String> large = new ArrayList<>();
try (Stream<Transaction> transactions = new TransactionCursor(repository, 4).stream()) {
transactions.filter(t -> t.amountInRupees() <= -40_000)
.limit(3) // stops early: later batches are never fetched
.forEach(t -> large.add(t.id() + " (" + t.amountInRupees() + ")"));
}
System.out.println("Found: " + large);
}
}
Important classes and methods
TransactionCursorloads the next batch only when the current one is used up, using keyset pagination (id > lastId), which stays fast on large tables, unlike growing offsets.- It implements
AutoCloseable, so try-with-resources releases it even after an exception. stream()wraps the iterator withSpliterators.spliteratorUnknownSize()and registersclose()withonClose().
Expected output
Full scan with hasNext() and next():
[db] SELECT ... WHERE id > 0 LIMIT 4
[db] SELECT ... WHERE id > 1004 LIMIT 4
[db] SELECT ... WHERE id > 1008 LIMIT 4
[db] cursor closed
Net movement: -208049
First 3 debits above Rs. 40,000, via a Stream:
[db] SELECT ... WHERE id > 0 LIMIT 4
[db] SELECT ... WHERE id > 1004 LIMIT 4
[db] cursor closed
Found: [1003 (-45000), 1005 (-62000), 1008 (-120000)]
How it runs
- The full scan reads 11 rows in three batches of up to four; the short last batch tells the cursor it is done.
- The stream stops after three large debits, so the third batch is never fetched.
- Both runs print "cursor closed" because try-with-resources closes the cursor and the stream.
10. Best Practices and Common Mistakes
Best practices
- Implement
Iterableso your collection works with for-each and, throughspliterator(), with streams. - Honour the contract:
hasNext()has no visible side effects and can be called repeatedly;next()throwsNoSuchElementExceptionat the end. - Make resource-backed iterators
AutoCloseableand use them in try-with-resources. - Thread safety: iterators are not thread-safe. Give each thread its own, and choose the collection type deliberately: fail-fast, weakly consistent or snapshot.
Common mistakes
- Removing from a collection inside a for-each loop, which triggers
ConcurrentModificationException. - Returning
nullinstead of throwing at the end of the sequence. - Leaking cursors by never closing database-backed iterators.
- Overengineering: writing a custom iterator when returning an unmodifiable
Listview would do.
11. When to Use and When Not to Use
Use it when you design your own data structure, need several traversal orders, want lazy or streaming access to large or remote data, or must hide how elements are stored.
Avoid writing one when a standard collection already fits, since the JDK's iterators are well tested. Also avoid it when callers need random access by index, which a list serves better, or when an internal iteration method such as forEach() or a stream expresses the job more clearly.
12. Advantages and Disadvantages
Advantages
- Encapsulation: storage can change without breaking callers.
- One loop style for arrays, trees and databases.
- Multiple independent traversals at the same time.
- Laziness: work happens one element at a time.
Disadvantages
- Extra classes for simple collections.
- Sequential only: no random access, and reverse traversal needs its own iterator.
- Concurrency surprises if the collection changes during iteration.
13. Comparison with Similar Patterns
| Aspect | Iterator | Visitor | Composite | Stream API |
|---|---|---|---|---|
| Purpose | Walk elements one by one | Apply operations to elements of different types | Model part-whole trees | Declarative pipeline over elements |
| Who drives the loop | The client (external iteration) | The structure or the visitor | Not applicable | The library (internal iteration) |
| Can stop early | Yes, just stop calling next() | Usually not | Not applicable | Yes, with short-circuit operations |
| Typical pairing | Walks a composite | Visits a composite | Traversed by iterators | Built on a Spliterator |
Choose an Iterator when the client controls the loop, a Visitor when you add many operations over a stable structure of different node types, and the Stream API when a declarative pipeline reads better. A Composite tree is the classic structure that needs iterators. The Adapter tutorial shows how to turn a paginated API into an Iterable.
14. Summary
- The iterator design pattern in Java gives sequential access to elements without exposing how they are stored.
- Purpose: encapsulate traversal and give every collection the same loop interface.
- Components: Iterator, Concrete Iterator, Aggregate (
Iterable), Concrete Aggregate, Client. - Each iterator keeps its own cursor;
hasNext()andnext()walk the elements. - Used for custom collections, tree traversal, database cursors and the whole JDK collections framework.
- Benefits: encapsulation, laziness and multiple traversal orders.
- Honour the contract, close resource-backed iterators, and never modify a collection mid-loop except through the iterator.
15. Java Design Pattern Interview Questions and Answers
Q1. What is the difference between fail-fast and weakly consistent iterators?
Fail-fast iterators, such as those of ArrayList and HashMap, throw ConcurrentModificationException on a best-effort basis when the collection is changed structurally during iteration, other than through the iterator itself. Weakly consistent iterators, such as those of ConcurrentHashMap, never throw; they may or may not reflect changes made after they were created. CopyOnWriteArrayList iterators read a snapshot and never see later changes.
Q2. How do you safely remove elements while iterating over a list?
Use the iterator's own remove(), which updates the expected modification count, or simply removeIf() (Java 8+). Calling list.remove() inside a for-each loop throws ConcurrentModificationException.
orders.removeIf(order -> order.status() == Status.CANCELLED);
Iterator<Order> it = orders.iterator();
while (it.hasNext()) {
if (it.next().isExpired()) it.remove(); // safe
}Q3. What is the difference between external and internal iteration?
With external iteration, the client drives the loop by calling hasNext() and next(), so it controls when to stop or pause. With internal iteration, such as forEach() or streams, the collection drives the loop and the client supplies a function. Internal iteration lets the library optimise, for example by running in parallel, while external iteration gives finer control.
Q4. A Spring Data repository method returns Stream<Payment>. What must the caller do?
Consume it inside a transaction and close it, ideally with try-with-resources, because the stream is backed by an open database cursor and connection. Forgetting to close it leaks connections from the pool, and using it outside a transaction fails or behaves unpredictably.
Q5. What does a Spliterator add beyond an Iterator?
A Spliterator (Java 8+) can traverse elements, and can also split itself into two parts so a parallel stream can process them on different threads. It reports characteristics such as ORDERED, SIZED and NONNULL that let the stream library optimise. Every Iterable gets a default, non-splitting implementation through spliterator().