Iterator Pattern #
A ProductCatalog stores products in a tree structure — categories have sub-categories, sub-categories have products. The code that needs to process all products should not care whether the data is stored as a flat slice, a tree, a linked list, or a streamed database query result. The Iterator Pattern separates two things that are often mixed together: how data is stored and how data is traversed. The collection provides an Iterator; the client uses the Iterator; nobody needs to know the internal storage details. If the storage structure ever changes from a slice to a database, the client does not need to change — only the Iterator implementation does.
What Is the Iterator Pattern? #
The Iterator Pattern is a behavioral design pattern that provides a way to access the elements of a collection sequentially without exposing its internal representation. The pattern separates traversal logic (how to traverse) from the data structure (how to store), so the two can evolve independently.
Three properties define the Iterator Pattern:
- Traversal encapsulation — the client does not need to know whether the collection is a slice, tree, graph, or database query result
- Multiple iterators — several traversals can run simultaneously on the same collection without interfering with each other
- Lazy evaluation — elements can be generated on demand rather than all at once; this enables iterating over very large datasets
flowchart LR
subgraph "Without Iterator"
C1[Client] -->|"knows internal structure"| SL["slice: catalog.products[i]"]
C1 -->|"knows how to traverse tree"| TR["tree: recursiveWalk(root)"]
C1 -->|"knows how to query DB"| DB["db: rows.Next(), rows.Scan()"]
end
subgraph "With Iterator"
C2[Client] -->|"it.HasNext() / it.Next()"| IT[Iterator\\ninterface]
IT --> SL2[SliceIterator]
IT --> TR2[TreeIterator]
IT --> DB2[DBIterator]
endIterators in Go: More Than One Way #
Go has several idiomatic ways to implement an Iterator, each with different trade-offs.
Way 1: Classic Interface (HasNext/Next) #
// Classic Iterator interface — the most explicit, closest to traditional OOP
type Iterator[T any] interface {
HasNext() bool
Next() T
Reset()
}
Way 2: Channel-based (idiomatic Go) #
// Channel-based iterator — natural for Go, supports range
func Iterate(collection []Product) <-chan Product {
ch := make(chan Product)
go func() {
defer close(ch)
for _, p := range collection {
ch <- p
}
}()
return ch
}
// Usage with range
for product := range Iterate(catalog.Products()) {
fmt.Println(product.Name)
}
Way 3: Functional Iterator (Go 1.23+) #
// iter.Seq is the type introduced in Go 1.23
// func(yield func(T) bool)
func (c *ProductCatalog) All() iter.Seq[Product] {
return func(yield func(Product) bool) {
for _, p := range c.products {
if !yield(p) {
return // yield returning false = stop iterating
}
}
}
}
// Usage with range (Go 1.23+)
for product := range catalog.All() {
fmt.Println(product.Name)
}
Way 4: Callback-based #
// Callback-based iterator — simple for small collections
func (c *ProductCatalog) ForEach(fn func(product Product) bool) {
for _, p := range c.products {
if !fn(p) {
break // fn returning false = stop
}
}
}
// Usage
catalog.ForEach(func(p Product) bool {
if p.Price > 1000000 {
return false // stop at the first expensive product
}
fmt.Println(p.Name)
return true
})
Comparing the four approaches:
| Approach | Pros | Cons | Best For |
|---|---|---|---|
| HasNext/Next interface | Familiar, explicit, easy to mock | Verbose, cannot use range | Libraries that many developers need to understand |
| Channel-based | Natural Go, uses range | Goroutine overhead, needs context for cancellation | Concurrent iteration, pipelines |
| Functional (iter.Seq) | Most idiomatic Go 1.23+, uses range | Go 1.23+ only | Modern codebases |
| Callback | Simplest | Lazy stop is awkward | Small collections, internal use |
Full Implementation: Product Catalog with Multiple Iterators #
Collection and Iterator Interfaces #
package catalog
// Product represents one product in the catalog.
type Product struct {
ID string
Name string
Category string
Price float64
InStock bool
Tags []string
}
// ProductIterator is the interface for iterating products.
// Uses the classic interface approach for compatibility with all Go versions.
type ProductIterator interface {
HasNext() bool
Next() *Product
Reset()
}
// ProductCollection is the Aggregate interface — a collection that can create iterators.
type ProductCollection interface {
CreateIterator() ProductIterator
CreateFilteredIterator(predicate func(*Product) bool) ProductIterator
CreateSortedIterator(less func(a, b *Product) bool) ProductIterator
Count() int
}
Concrete Aggregate: ProductCatalog #
package catalog
import (
"sort"
)
// ProductCatalog is the Concrete Aggregate — it stores products in a slice.
type ProductCatalog struct {
products []*Product
}
func NewProductCatalog() *ProductCatalog {
return &ProductCatalog{products: make([]*Product, 0)}
}
// Add adds a product to the catalog.
func (c *ProductCatalog) Add(product *Product) {
c.products = append(c.products, product)
}
// Count returns the number of products in the catalog.
func (c *ProductCatalog) Count() int { return len(c.products) }
// CreateIterator creates a basic iterator that traverses all products.
func (c *ProductCatalog) CreateIterator() ProductIterator {
return &SliceIterator{
products: c.products,
index: 0,
}
}
// CreateFilteredIterator creates an iterator that only returns products
// satisfying a given predicate.
func (c *ProductCatalog) CreateFilteredIterator(predicate func(*Product) bool) ProductIterator {
return &FilteredIterator{
source: c.CreateIterator(),
predicate: predicate,
}
}
// CreateSortedIterator creates an iterator that returns products in a given order.
// Makes a copy of the slice to avoid modifying the original collection.
func (c *ProductCatalog) CreateSortedIterator(less func(a, b *Product) bool) ProductIterator {
sorted := make([]*Product, len(c.products))
copy(sorted, c.products)
sort.Slice(sorted, func(i, j int) bool {
return less(sorted[i], sorted[j])
})
return &SliceIterator{products: sorted, index: 0}
}
// InStockIterator shortcut: iterator for in-stock products only.
func (c *ProductCatalog) InStockIterator() ProductIterator {
return c.CreateFilteredIterator(func(p *Product) bool {
return p.InStock
})
}
// CategoryIterator shortcut: iterator for a specific category.
func (c *ProductCatalog) CategoryIterator(category string) ProductIterator {
return c.CreateFilteredIterator(func(p *Product) bool {
return p.Category == category
})
}
// PriceRangeIterator shortcut: iterator for a specific price range.
func (c *ProductCatalog) PriceRangeIterator(minPrice, maxPrice float64) ProductIterator {
return c.CreateFilteredIterator(func(p *Product) bool {
return p.Price >= minPrice && p.Price <= maxPrice
})
}
Concrete Iterators #
package catalog
// SliceIterator traverses products in a slice sequentially.
type SliceIterator struct {
products []*Product
index int
}
func (it *SliceIterator) HasNext() bool {
return it.index < len(it.products)
}
func (it *SliceIterator) Next() *Product {
if !it.HasNext() {
return nil
}
product := it.products[it.index]
it.index++
return product
}
func (it *SliceIterator) Reset() {
it.index = 0
}
// FilteredIterator wraps another iterator and only returns
// elements satisfying a predicate. This is a Decorator on the Iterator.
type FilteredIterator struct {
source ProductIterator
predicate func(*Product) bool
next *Product // the next element already prefetched
fetched bool // whether prefetch has happened
}
func (it *FilteredIterator) prefetch() {
for it.source.HasNext() {
product := it.source.Next()
if it.predicate(product) {
it.next = product
it.fetched = true
return
}
}
it.next = nil
it.fetched = true
}
func (it *FilteredIterator) HasNext() bool {
if !it.fetched {
it.prefetch()
}
return it.next != nil
}
func (it *FilteredIterator) Next() *Product {
if !it.fetched {
it.prefetch()
}
result := it.next
it.fetched = false
it.next = nil
return result
}
func (it *FilteredIterator) Reset() {
it.source.Reset()
it.next = nil
it.fetched = false
}
// ReverseIterator traverses the slice from back to front.
type ReverseIterator struct {
products []*Product
index int
}
func NewReverseIterator(products []*Product) ProductIterator {
return &ReverseIterator{
products: products,
index: len(products) - 1,
}
}
func (it *ReverseIterator) HasNext() bool {
return it.index >= 0
}
func (it *ReverseIterator) Next() *Product {
if !it.HasNext() {
return nil
}
product := it.products[it.index]
it.index--
return product
}
func (it *ReverseIterator) Reset() {
it.index = len(it.products) - 1
}
Usage: The Client Does Not Know the Internal Structure #
func main() {
catalog := catalog.NewProductCatalog()
catalog.Add(&catalog.Product{ID: "P001", Name: "Laptop Pro", Category: "Electronics",
Price: 15000000, InStock: true, Tags: []string{"laptop", "premium"}})
catalog.Add(&catalog.Product{ID: "P002", Name: "Wireless Mouse", Category: "Electronics",
Price: 350000, InStock: true, Tags: []string{"mouse", "wireless"}})
catalog.Add(&catalog.Product{ID: "P003", Name: "Standing Desk", Category: "Furniture",
Price: 5000000, InStock: false, Tags: []string{"desk", "ergonomic"}})
catalog.Add(&catalog.Product{ID: "P004", Name: "Mechanical Keyboard", Category: "Electronics",
Price: 2500000, InStock: true, Tags: []string{"keyboard", "mechanical"}})
// Basic iterator: all products
fmt.Println("=== All Products ===")
it := catalog.CreateIterator()
for it.HasNext() {
p := it.Next()
fmt.Printf(" %s: Rp %.0f\n", p.Name, p.Price)
}
// Iterator with a filter: only in-stock items
fmt.Println("\n=== In-Stock Products ===")
it = catalog.InStockIterator()
for it.HasNext() {
p := it.Next()
fmt.Printf(" %s\n", p.Name)
}
// Iterator with sorting: cheapest first
fmt.Println("\n=== Sorted by Price (Cheapest) ===")
it = catalog.CreateSortedIterator(func(a, b *catalog.Product) bool {
return a.Price < b.Price
})
for it.HasNext() {
p := it.Next()
fmt.Printf(" %s: Rp %.0f\n", p.Name, p.Price)
}
// Multiple simultaneous iterators — they do not interfere
fmt.Println("\n=== Multiple Simultaneous Iterators ===")
it1 := catalog.CreateIterator()
it2 := catalog.CreateIterator()
_ = it1.Next() // it1 advances one step
fmt.Printf("it1 next: %s\n", it1.Next().Name)
fmt.Printf("it2 next: %s\n", it2.Next().Name) // it2 is still at the start
}
Tree Iterator: Recursive Traversal #
For tree structures (like file systems or nested product categories), the Iterator hides the recursive traversal complexity from the client.
package tree
// CategoryNode represents a node in the category tree.
type CategoryNode struct {
Name string
Products []Product
Children []*CategoryNode
}
// DFSIterator performs depth-first search traversal on the category tree.
// The client does not need to know this is a tree — just use HasNext/Next.
type DFSIterator struct {
stack []*CategoryNode
products []Product // product buffer from the current node
prodIdx int
}
func NewDFSIterator(root *CategoryNode) *DFSIterator {
it := &DFSIterator{
stack: make([]*CategoryNode, 0),
}
it.stack = append(it.stack, root)
it.loadNextNode()
return it
}
func (it *DFSIterator) loadNextNode() {
it.products = nil
it.prodIdx = 0
for len(it.stack) > 0 && len(it.products) == 0 {
// Pop from the stack
node := it.stack[len(it.stack)-1]
it.stack = it.stack[:len(it.stack)-1]
// Push children onto the stack (in reverse order for correct DFS)
for i := len(node.Children) - 1; i >= 0; i-- {
it.stack = append(it.stack, node.Children[i])
}
it.products = node.Products
}
}
func (it *DFSIterator) HasNext() bool {
return it.prodIdx < len(it.products)
}
func (it *DFSIterator) Next() Product {
if !it.HasNext() {
return Product{}
}
p := it.products[it.prodIdx]
it.prodIdx++
if it.prodIdx >= len(it.products) {
it.loadNextNode()
}
return p
}
func (it *DFSIterator) Reset() {
// Reset is not easy for a tree iterator — create a new iterator
panic("DFSIterator does not support Reset — create a new iterator")
}
// BFSIterator performs breadth-first search traversal.
type BFSIterator struct {
queue []*CategoryNode
products []Product
prodIdx int
}
func NewBFSIterator(root *CategoryNode) *BFSIterator {
it := &BFSIterator{
queue: []*CategoryNode{root},
}
it.loadNextNode()
return it
}
func (it *BFSIterator) loadNextNode() {
it.products = nil
it.prodIdx = 0
for len(it.queue) > 0 && len(it.products) == 0 {
node := it.queue[0]
it.queue = it.queue[1:]
it.queue = append(it.queue, node.Children...)
it.products = node.Products
}
}
func (it *BFSIterator) HasNext() bool { return it.prodIdx < len(it.products) }
func (it *BFSIterator) Next() Product {
if !it.HasNext() {
return Product{}
}
p := it.products[it.prodIdx]
it.prodIdx++
if it.prodIdx >= len(it.products) {
it.loadNextNode()
}
return p
}
func (it *BFSIterator) Reset() {
panic("BFSIterator does not support Reset — create a new iterator")
}
Database Cursor as an Iterator #
The Iterator Pattern is very natural for database cursors — each row is only loaded on demand, not all at once.
package dbiter
import (
"context"
"database/sql"
"fmt"
)
// UserIterator iterates over database rows lazily — one row per Next().
// Not all rows are loaded into memory at once.
type UserIterator struct {
rows *sql.Rows
current *User
err error
done bool
}
// User represents one row from the users table.
type User struct {
ID int
Name string
Email string
Role string
}
// NewUserIterator creates an iterator from a database query.
func NewUserIterator(ctx context.Context, db *sql.DB, query string, args ...interface{}) (*UserIterator, error) {
rows, err := db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("query failed: %w", err)
}
return &UserIterator{rows: rows}, nil
}
// HasNext checks whether there is another row.
// Internally, this prefetches the next row.
func (it *UserIterator) HasNext() bool {
if it.done {
return false
}
if !it.rows.Next() {
it.done = true
it.rows.Close()
return false
}
var u User
if err := it.rows.Scan(&u.ID, &u.Name, &u.Email, &u.Role); err != nil {
it.err = err
it.done = true
return false
}
it.current = &u
return true
}
// Next returns the current user (already prefetched by HasNext).
func (it *UserIterator) Next() *User {
return it.current
}
// Error returns the last error, if any.
func (it *UserIterator) Error() error { return it.err }
// Close closes the database rows.
func (it *UserIterator) Close() error { return it.rows.Close() }
// Usage
func processAllUsers(ctx context.Context, db *sql.DB) error {
it, err := NewUserIterator(ctx, db,
"SELECT id, name, email, role FROM users WHERE is_active = true ORDER BY id")
if err != nil {
return err
}
defer it.Close()
for it.HasNext() {
user := it.Next()
// Process one user at a time — not all of them in memory
fmt.Printf("Processing: %s (%s)\n", user.Name, user.Email)
}
return it.Error()
}
Channel-based Iterator for Concurrent Processing #
Channel-based iterators are a great fit when data is produced asynchronously or needs concurrent processing.
package pipeline
import (
"context"
"fmt"
)
// StreamProducts produces products one by one through a channel.
// Consumers can read products without knowing how they are generated.
func StreamProducts(ctx context.Context, catalog *ProductCatalog) <-chan *Product {
ch := make(chan *Product, 10) // buffer to reduce blocking
go func() {
defer close(ch)
it := catalog.CreateIterator()
for it.HasNext() {
select {
case <-ctx.Done():
return // context cancelled — stop streaming
case ch <- it.Next():
// product delivered to the consumer
}
}
}()
return ch
}
// FilterStream wraps a channel stream with a filter — a Decorator on the channel iterator.
func FilterStream(ctx context.Context, in <-chan *Product, predicate func(*Product) bool) <-chan *Product {
out := make(chan *Product, 10)
go func() {
defer close(out)
for {
select {
case <-ctx.Done():
return
case p, ok := <-in:
if !ok {
return
}
if predicate(p) {
out <- p
}
}
}
}()
return out
}
// Usage: pipeline with channel iterators
func buildPipeline(ctx context.Context, catalog *ProductCatalog) {
// Stream all products
all := StreamProducts(ctx, catalog)
// Filter only in-stock items
inStock := FilterStream(ctx, all, func(p *Product) bool {
return p.InStock
})
// Filter only items under 5 million
affordable := FilterStream(ctx, inStock, func(p *Product) bool {
return p.Price < 5000000
})
// Consumer — process the pipeline result
for product := range affordable {
fmt.Printf("Affordable in-stock: %s (Rp %.0f)\n", product.Name, product.Price)
}
}
Iterators with Helper Functions #
Helper functions that work with generic iterators make client code more expressive.
// Collect gathers all elements from an iterator into a slice.
func Collect(it ProductIterator) []*Product {
var results []*Product
for it.HasNext() {
results = append(results, it.Next())
}
return results
}
// First returns the first element satisfying the predicate.
func First(it ProductIterator, predicate func(*Product) bool) *Product {
for it.HasNext() {
p := it.Next()
if predicate(p) {
return p
}
}
return nil
}
// Count counts the elements satisfying the predicate.
func Count(it ProductIterator, predicate func(*Product) bool) int {
count := 0
for it.HasNext() {
if predicate(it.Next()) {
count++
}
}
return count
}
// Reduce aggregates all elements into a single value.
func Reduce[T any](it ProductIterator, initial T, fn func(acc T, p *Product) T) T {
acc := initial
for it.HasNext() {
acc = fn(acc, it.Next())
}
return acc
}
// Usage of the helper functions
func analyzeStock(catalog *ProductCatalog) {
it := catalog.CreateIterator()
totalValue := Reduce(it, 0.0, func(acc float64, p *Product) float64 {
if p.InStock {
return acc + p.Price
}
return acc
})
fmt.Printf("Total value in stock: Rp %.0f\n", totalValue)
it.Reset()
cheapest := First(catalog.InStockIterator(), func(p *Product) bool {
return p.Price < 500000
})
if cheapest != nil {
fmt.Printf("Cheapest under 500k: %s\n", cheapest.Name)
}
}
Testing the Iterator Pattern #
func TestSliceIterator_TraversesAllElements(t *testing.T) {
catalog := NewProductCatalog()
catalog.Add(&Product{ID: "P1", Name: "Product 1", Price: 100})
catalog.Add(&Product{ID: "P2", Name: "Product 2", Price: 200})
catalog.Add(&Product{ID: "P3", Name: "Product 3", Price: 300})
it := catalog.CreateIterator()
var visited []string
for it.HasNext() {
p := it.Next()
visited = append(visited, p.ID)
}
if len(visited) != 3 {
t.Errorf("expected 3 elements, got %d", len(visited))
}
if visited[0] != "P1" || visited[1] != "P2" || visited[2] != "P3" {
t.Errorf("wrong traversal order: %v", visited)
}
}
func TestSliceIterator_Reset(t *testing.T) {
catalog := NewProductCatalog()
catalog.Add(&Product{ID: "P1", Name: "Product 1"})
catalog.Add(&Product{ID: "P2", Name: "Product 2"})
it := catalog.CreateIterator()
_ = it.Next() // advance one step
_ = it.Next() // advance again
if it.HasNext() {
t.Error("expected no more elements")
}
it.Reset()
if !it.HasNext() {
t.Error("after reset, should have elements again")
}
if p := it.Next(); p.ID != "P1" {
t.Errorf("after reset, first element should be P1, got %s", p.ID)
}
}
func TestFilteredIterator_OnlyReturnsMatching(t *testing.T) {
catalog := NewProductCatalog()
catalog.Add(&Product{ID: "P1", Name: "Laptop", InStock: true, Price: 10000000})
catalog.Add(&Product{ID: "P2", Name: "Mouse", InStock: false, Price: 200000})
catalog.Add(&Product{ID: "P3", Name: "Keyboard", InStock: true, Price: 1500000})
it := catalog.InStockIterator()
var inStock []string
for it.HasNext() {
inStock = append(inStock, it.Next().ID)
}
if len(inStock) != 2 {
t.Errorf("expected 2 in-stock products, got %d", len(inStock))
}
if inStock[0] != "P1" || inStock[1] != "P3" {
t.Errorf("wrong filtered elements: %v", inStock)
}
}
func TestMultipleIterators_IndependentTraversal(t *testing.T) {
catalog := NewProductCatalog()
for i := 1; i <= 5; i++ {
catalog.Add(&Product{ID: fmt.Sprintf("P%d", i), Name: fmt.Sprintf("Product %d", i)})
}
it1 := catalog.CreateIterator()
it2 := catalog.CreateIterator()
// Advance it1 two steps
_ = it1.Next()
p1 := it1.Next()
// it2 is still at the start — unaffected by it1
p2 := it2.Next()
if p1.ID != "P2" {
t.Errorf("it1 second element: expected P2, got %s", p1.ID)
}
if p2.ID != "P1" {
t.Errorf("it2 first element: expected P1, got %s", p2.ID)
}
}
func TestSortedIterator_CorrectOrder(t *testing.T) {
catalog := NewProductCatalog()
catalog.Add(&Product{ID: "P3", Price: 300})
catalog.Add(&Product{ID: "P1", Price: 100})
catalog.Add(&Product{ID: "P2", Price: 200})
it := catalog.CreateSortedIterator(func(a, b *Product) bool {
return a.Price < b.Price
})
var order []string
for it.HasNext() {
order = append(order, it.Next().ID)
}
if order[0] != "P1" || order[1] != "P2" || order[2] != "P3" {
t.Errorf("wrong sort order: %v", order)
}
}
When to Use and When Not to #
USE Iterator if:
✓ You need to traverse a collection with complex internal structure (tree, graph)
✓ You need multiple simultaneous traversals over the same collection
✓ You need lazy evaluation for large datasets (database cursors, file streams)
✓ You want to separate traversal logic from storage logic
✓ You need several different traversal modes (DFS, BFS, filtered, sorted)
AVOID Iterator if:
✗ The collection is a simple slice/array — for-range is enough and more idiomatic
✗ You only need one traversal, once — direct for-range is simpler
✗ The collection is very small — the interface overhead is not worth it
✗ Go 1.22+ is available and the collection already implements iter.Seq — use that
Iterators in the Go Standard Library
bufio.Scanneris an iterator —scanner.Scan()is equivalent toHasNext(),scanner.Text()is equivalent toNext().database/sql.Rowsis also an iterator —rows.Next()androws.Scan().filepath.Walkuses callback-based iteration. Understanding the Iterator Pattern helps you read and understand these standard APIs more intuitively.
Iterator Review Checklist #
DESIGN:
□ The Iterator encapsulates traversal — the client does not know the collection's internal structure
□ Each Iterator is an independent object — several can run simultaneously
□ The collection provides factory methods (CreateIterator) to create iterators
□ Iterators do not modify the collection during traversal
IMPLEMENTATION:
□ HasNext() has no side effects — calling it multiple times is safe
□ Next() is only called after HasNext() returns true
□ Reset() returns the iterator to its initial position
□ The FilteredIterator prefetches correctly for accurate HasNext()
LAZY EVALUATION:
□ Database iterators do not load all rows at once
□ Channel iterators use a context for cancellation
□ Iterators close resources (rows.Close(), channel) when finished or cancelled
TESTING:
□ Every element is visited exactly once
□ Traversal order is verified (for sorted/DFS/BFS iterators)
□ Multiple iterators run independently of each other
□ Reset() correctly returns to the initial position
□ The FilteredIterator only returns elements satisfying the predicate
Summary #
- Iterator separates “how to traverse” from “how to store” — the client uses
HasNext()andNext()without needing to know whether the collection is a slice, tree, database cursor, or generator.- Four approaches in Go: the classic interface (HasNext/Next), channel-based, functional (iter.Seq in Go 1.23+), and callback — pick according to context and Go version.
- Multiple independent iterators — each iterator has its own traversal state; several can run simultaneously on the same collection without interfering.
- A FilteredIterator is a Decorator on the Iterator — it wraps another iterator and only passes through elements satisfying the predicate; no need to modify the original collection.
- Lazy evaluation for large datasets — database cursors and channel-based iterators load only one element at a time; no need to load the entire dataset into memory.
- Helper functions like
Collect,First,Count, andReducemake client code more expressive without manual loops.- The standard library already uses Iterators:
bufio.Scanner,database/sql.Rows, andfilepath.Walkall implement the same concept.- For simple slices, for-range is enough — an Iterator is only needed when there is traversal complexity or a need for multiple simultaneous traversals.