How to Implement the Iterator Pattern in Go

Implement Go's Iterator Pattern by returning an iter.Seq[T] function that yields values via a callback to enable memory-efficient streaming loops.

How to Implement the Iterator Pattern in Go

You are parsing a log file that is 500 megabytes. You need to find every line containing an error. The old approach is to read the entire file into a slice of strings, loop over the slice, and discard it when you are done. That spikes memory usage and forces the garbage collector to work harder. You want to process one line at a time, keeping memory flat. Other languages give you an iterator interface with Next and HasNext methods. Go used to force you to write that boilerplate or use a channel. Go 1.23 introduced a built-in iterator type that feels like a range loop but streams data lazily.

Iterators stream data. They don't hoard it.

Concept: A recipe, not a bag

An iterator is a recipe for generating values, not a bag of values. Think of it like a tap. You don't fill a giant bucket before you start washing dishes. You turn the handle, and water flows exactly when you need it. When you are done, you turn it off.

In Go, iter.Seq[T] is a function type. It takes a callback argument. You call the callback to hand a value to the loop. The loop decides when to ask for the next one. This keeps memory usage constant regardless of how many items exist. The iterator produces values on demand. The consumer pulls values at its own pace.

The loop drives the iterator. The iterator yields control.

Minimal example

Here is the simplest iterator: a function that yields integers from one up to a limit.

package main

import (
	"fmt"
	"iter"
)

// CountTo returns an iterator that yields integers from 1 up to n.
func CountTo(n int) iter.Seq[int] {
	return func(yield func(int) bool) {
		// Loop through the range.
		for i := 1; i <= n; i++ {
			// yield sends the value to the range loop.
			// It returns false if the loop breaks early.
			if !yield(i) {
				return
			}
		}
	}
}

func main() {
	for num := range CountTo(5) {
		fmt.Println(num)
	}
}

Yield is a callback. Treat it like one.

How the mechanics work

The type iter.Seq[T] is just a function signature: func(yield func(T) bool). When you write for v := range CountTo(5), the compiler generates code that calls your function and passes a hidden callback. Inside your function, you call yield(value). That call hands value to the loop body. The loop body runs. When the loop body finishes, execution returns to your iterator function, right after the yield call.

This is a trampoline pattern. The iterator function and the loop body take turns. The iterator calls yield, control jumps to the loop. The loop finishes, control jumps back to the iterator. This avoids recursion. You can iterate over millions of items without blowing the stack.

The yield function returns a boolean. This is the signal. True means keep going. False means stop. This is how the loop communicates back to the iterator. If the loop hits a break, yield returns false. Your iterator should check the return value and return immediately. This cooperative cancellation prevents wasted work. If you ignore the return value, your iterator keeps generating values nobody will see.

You also cannot resume an iterator. Once the function returns, the sequence is done. You cannot pause and restart it. If you need pausing, you need a different design, like a channel or a stateful struct.

The loop drives the iterator. The iterator yields control.

Realistic example: Streaming lines

Here is a realistic iterator that splits a byte slice into lines without allocating a slice of strings.

package main

import (
	"bytes"
	"fmt"
	"iter"
)

// Lines yields each line from a byte slice, including the newline character.
// It avoids allocating a slice of all lines, keeping memory usage low.
func Lines(data []byte) iter.Seq[[]byte] {
	return func(yield func([]byte) bool) {
		// Scan through the data, finding newlines.
		for len(data) > 0 {
			// Find the next newline index.
			idx := bytes.IndexByte(data, '\n')
			var line []byte
			if idx >= 0 {
				// Slice up to and including the newline.
				// Use [:idx+1:idx+1] to cap capacity and prevent growth.
				line = data[:idx+1 : idx+1]
				// Advance data past this line.
				data = data[idx+1:]
			} else {
				// No more newlines. Take the rest as the final line.
				line = data
				data = nil
			}
			// Yield the line. Stop if the loop breaks.
			if !yield(line) {
				return
			}
		}
	}
}

func main() {
	raw := []byte("hello\nworld\nfoo\n")
	for line := range Lines(raw) {
		fmt.Printf("%q\n", line)
	}
}

The three-index slice data[:i:i] looks unusual. The first index is the start. The second is the end. The third is the capacity. By setting capacity equal to the end, you tell the runtime that this slice cannot grow. If you return a slice without capping capacity, the caller might accidentally write past the end of the original buffer. Capping capacity is a defensive habit. It prevents subtle corruption bugs when returning sub-slices.

Iterators don't carry errors. If your source can fail, return a tuple (iter.Seq[T], error) from the factory function. Or use a channel where you can send an error and close. The iterator pattern shines when success is the only path or when errors are handled upstream.

If your iterator does I/O, accept a context.Context as the first argument to the generator function, not the iterator itself. The iterator type iter.Seq has no room for context. Wrap the context in the closure or pass it to the factory.

Cap the slice capacity. Returning a sub-slice is safe only if you bound the growth.

Pitfalls and errors

The compiler rejects iter.Seq with undefined: iter.Seq if you run the code with Go 1.22 or older. You need Go 1.23+.

Ignoring the return value of yield is a common mistake. If the loop breaks, yield returns false. If your iterator keeps running, it wastes CPU cycles generating values nobody will see. Always check if !yield(v) { return }.

You cannot pass an iterator by value to a function that expects a different signature. iter.Seq[T] is a specific function type. If you try to assign a function with the wrong signature, the compiler complains with cannot use func literal (value of type func(...)) as iter.Seq[...] value in return argument.

Goroutine leaks do not happen with iterators because iterators are synchronous. The iterator runs on the same stack as the loop. If the loop exits, the iterator function returns. There is no background goroutine to leak. This is a major advantage over channel-based generators.

Check the yield return. Wasted work is a silent leak.

Decision: when to use iterators

Use iter.Seq when you want lazy evaluation with zero allocation overhead and synchronous execution. Use a channel when you need to decouple the producer and consumer, run them in separate goroutines, or buffer items. Use a slice when the dataset fits comfortably in memory and you need random access or multiple passes. Use a standard for loop over a slice or array when you already have the data and don't need the abstraction of an iterator. Use a generator function returning a channel when you need to produce values indefinitely or handle errors that require signaling beyond a simple return.

Pick the tool that matches the data flow. Laziness is a feature, not a requirement.

Where to go next