Iterator Pattern — Middle¶
1. What this level adds¶
Junior taught the three shapes. Middle is about using them well:
iter.Seq[T]anditer.Seq2[K,V]deep-dive (Go 1.23+).- Pull vs push iterators — when each is appropriate.
- Composing iterators: Map, Filter, Take, Skip, Zip, Reduce.
- Lazy vs eager evaluation.
- Stateful iterators with cleanup (Close).
- Paginated API iterators (network roundtrip per page).
- Generic iterator helpers.
- Channel-based generators and their gotchas.
- Testing iterators.
2. Table of Contents¶
- What this level adds
- Table of Contents
- Push vs Pull iterators
iter.Seqanditer.Seq2- Composing iterators
- Lazy evaluation
- Stateful iterators with Close
- Paginated API iterators
- Generic iterator helpers
- Channel-based generators
- Testing iterators
- Common middle-level mistakes
- Performance notes
- Tricky points
- Test
- Cheat sheet
3. Push vs Pull iterators¶
Two fundamental modes:
- Push: the iterator pushes values to a caller-supplied function (
yield(v)). The iterator drives.iter.Seq[T]is push. - Pull: the caller pulls values one at a time.
Next() bool+Value()is pull.iter.Pullconverts push to pull.
// Push (iter.Seq)
for v := range slices.Values(xs) {
if v > 100 { break }
use(v)
}
// Pull (iter.Pull)
next, stop := iter.Pull(slices.Values(xs))
defer stop()
for {
v, ok := next()
if !ok { break }
if v > 100 { break }
use(v)
}
3.1 When to use which¶
| Scenario | Push (iter.Seq) | Pull (iter.Pull / Next-Value) |
|---|---|---|
| Simple iteration | ✓ | works but verbose |
| Two iterators in lockstep | hard | natural |
| Lookahead without consuming | hard | natural |
| Producer in a goroutine | hard | natural (channel-based) |
| Composition (map/filter/take) | ✓ | possible but more code |
| Performance | ~5% faster | slightly slower (goroutine overhead in iter.Pull) |
For 95% of code, push is the right default. Pull is for the cases push can't handle.
4. iter.Seq and iter.Seq2¶
The Go 1.23 iterator types:
A function that produces values. The yield callback is supplied by range; the iterator calls it for each value.
4.1 Writing an iter.Seq¶
func Range(start, stop int) iter.Seq[int] {
return func(yield func(int) bool) {
for i := start; i < stop; i++ {
if !yield(i) { return }
}
}
}
for i := range Range(0, 10) {
if i == 5 { break } // causes yield to return false
fmt.Println(i)
}
4.2 The !yield(v) { return } discipline¶
When the caller breaks/returns from the loop body, yield returns false. The iterator must:
- Not call
yieldagain. - Clean up any resources.
- Return promptly.
Forgetting !yield checks means break does nothing — the iterator continues running.
4.3 iter.Seq2 for key-value¶
func Enumerate[T any](s []T) iter.Seq2[int, T] {
return func(yield func(int, T) bool) {
for i, v := range s {
if !yield(i, v) { return }
}
}
}
for i, v := range Enumerate([]string{"a", "b", "c"}) {
fmt.Println(i, v)
}
Same pattern, with two values per yield.
5. Composing iterators¶
Iterators are first-class values; you can compose them.
5.1 Map¶
func Map[T, U any](seq iter.Seq[T], f func(T) U) iter.Seq[U] {
return func(yield func(U) bool) {
for v := range seq {
if !yield(f(v)) { return }
}
}
}
// Usage:
doubled := Map(slices.Values([]int{1, 2, 3}), func(n int) int { return n * 2 })
for v := range doubled { fmt.Println(v) } // 2, 4, 6
5.2 Filter¶
func Filter[T any](seq iter.Seq[T], pred func(T) bool) iter.Seq[T] {
return func(yield func(T) bool) {
for v := range seq {
if pred(v) {
if !yield(v) { return }
}
}
}
}
5.3 Take, Skip, Zip¶
func Take[T any](seq iter.Seq[T], n int) iter.Seq[T] {
return func(yield func(T) bool) {
i := 0
for v := range seq {
if i >= n { return }
if !yield(v) { return }
i++
}
}
}
func Skip[T any](seq iter.Seq[T], n int) iter.Seq[T] {
return func(yield func(T) bool) {
i := 0
for v := range seq {
if i >= n {
if !yield(v) { return }
}
i++
}
}
}
5.4 Pipeline¶
result := Take(
Filter(
Map(
slices.Values(numbers),
func(n int) int { return n * 2 },
),
func(n int) bool { return n > 10 },
),
5,
)
for v := range result {
fmt.Println(v)
}
Each call returns a new iter.Seq. No work happens until range runs.
5.5 Lazy evaluation¶
seq := Map(infiniteNumbers(), func(n int) int {
fmt.Println("computing", n)
return n * 2
})
for v := range Take(seq, 3) {
fmt.Println("got", v)
}
// computes only 3 elements; the rest are never evaluated
This is the feature of iter.Seq — lazy by construction. Composing iterators doesn't materialize the result.
6. Lazy evaluation¶
Compare:
// Eager — materializes all 1M elements
results := slices.Map(million, func(x int) int { return expensive(x) })
return results[:5]
// Lazy — only computes 5
seq := Map(slices.Values(million), expensive)
result := slices.Collect(Take(seq, 5))
return result
When you need only the first N (or filter most away), lazy saves work proportional to the difference.
When you need all elements, lazy doesn't help — and adds slight per-element overhead.
The rule: use lazy when intermediate operations dominate cost, eager when the result is always fully consumed.
7. Stateful iterators with Close¶
Some iterators hold resources (open files, DB connections, network sockets). They need explicit cleanup.
7.1 The Close pattern¶
type fileIterator struct {
f *os.File
scanner *bufio.Scanner
}
func NewFileIterator(path string) (*fileIterator, error) {
f, err := os.Open(path)
if err != nil { return nil, err }
return &fileIterator{
f: f,
scanner: bufio.NewScanner(f),
}, nil
}
func (i *fileIterator) Next() bool { return i.scanner.Scan() }
func (i *fileIterator) Value() string { return i.scanner.Text() }
func (i *fileIterator) Err() error { return i.scanner.Err() }
func (i *fileIterator) Close() error { return i.f.Close() }
Caller:
it, err := NewFileIterator(path)
if err != nil { return err }
defer it.Close()
for it.Next() {
line := it.Value()
/* ... */
}
return it.Err()
7.2 Bridging to iter.Seq with cleanup¶
func FileLines(path string) (iter.Seq[string], func(), error) {
f, err := os.Open(path)
if err != nil { return nil, nil, err }
scanner := bufio.NewScanner(f)
seq := func(yield func(string) bool) {
for scanner.Scan() {
if !yield(scanner.Text()) { return }
}
}
return seq, func() { f.Close() }, nil
}
// Usage:
seq, cleanup, err := FileLines("data.txt")
if err != nil { return err }
defer cleanup()
for line := range seq {
/* ... */
}
Return the iter.Seq alongside a cleanup function. Caller defers cleanup.
8. Paginated API iterators¶
A common real-world iterator: paginate through an HTTP API.
func ListUsers(ctx context.Context, client *APIClient) iter.Seq2[*User, error] {
return func(yield func(*User, error) bool) {
cursor := ""
for {
page, err := client.UsersPage(ctx, cursor)
if err != nil {
yield(nil, err)
return
}
for _, u := range page.Users {
if !yield(u, nil) { return }
}
if page.NextCursor == "" {
return
}
cursor = page.NextCursor
}
}
}
// Usage:
for user, err := range ListUsers(ctx, client) {
if err != nil { return err }
process(user)
}
The iterator hides the pagination from the caller. Each "next user" might be a cached value from the current page or trigger an API call for the next page.
8.1 Why iter.Seq2 with error¶
The error case fits naturally as a second return value. Alternative: return errors via a separate accessor (like rows.Err()).
type ResultSeq[T any] struct {
seq iter.Seq[T]
err error
}
func (r *ResultSeq[T]) Err() error { return r.err }
Both work. iter.Seq2[T, error] is more idiomatic for the new style.
9. Generic iterator helpers¶
A utility package for reusable iterators:
package iters
import "iter"
func Collect[T any](seq iter.Seq[T]) []T {
var result []T
for v := range seq { result = append(result, v) }
return result
}
func Count[T any](seq iter.Seq[T]) int {
n := 0
for range seq { n++ }
return n
}
func ForEach[T any](seq iter.Seq[T], f func(T)) {
for v := range seq { f(v) }
}
func Reduce[T, U any](seq iter.Seq[T], init U, f func(U, T) U) U {
acc := init
for v := range seq { acc = f(acc, v) }
return acc
}
The Go 1.23 stdlib has some of these (slices.Collect, slices.SortedFunc, etc.). Your custom helpers fill the gaps.
10. Channel-based generators¶
Pre-Go 1.23, channels were the iterator primitive:
func PrimeNumbers(max int) <-chan int {
ch := make(chan int)
go func() {
defer close(ch)
for n := 2; n < max; n++ {
if isPrime(n) { ch <- n }
}
}()
return ch
}
for p := range PrimeNumbers(100) {
fmt.Println(p)
}
Pros: - Producer in separate goroutine — possibly parallel. - Works with range directly.
Cons: - Goroutine leak on early termination. The producer blocks on the channel forever if the consumer stops reading.
Fix: accept context.Context and check <-ctx.Done():
func PrimeNumbers(ctx context.Context, max int) <-chan int {
ch := make(chan int)
go func() {
defer close(ch)
for n := 2; n < max; n++ {
if isPrime(n) {
select {
case ch <- n:
case <-ctx.Done(): return
}
}
}
}()
return ch
}
For most uses, prefer iter.Seq over channels. The exception: when the producer must run in a separate goroutine (e.g., reading from a network).
11. Testing iterators¶
11.1 Verify all elements¶
func TestPrimes(t *testing.T) {
got := slices.Collect(Primes(20))
want := []int{2, 3, 5, 7, 11, 13, 17, 19}
if !slices.Equal(got, want) {
t.Errorf("got %v, want %v", got, want)
}
}
slices.Collect materialises into a slice for easy comparison.
11.2 Verify early termination¶
func TestEarlyTermination(t *testing.T) {
count := 0
for v := range Primes(1000000) {
count++
if count >= 3 { break }
_ = v
}
if count != 3 { t.Errorf("got %d, want 3", count) }
}
The iterator should respect break. If it computes all 1M primes anyway, it's broken.
11.3 Verify cleanup¶
func TestFileIterCleanup(t *testing.T) {
f := makeTempFile(t)
it, cleanup, _ := FileLines(f)
defer cleanup()
for range it { break } // exit early
// Verify the file is closed:
if _, err := f.Read(buf); !errors.Is(err, os.ErrClosed) {
t.Errorf("file not closed after cleanup")
}
}
12. Common middle-level mistakes¶
12.1 Materialising large sequences¶
// Anti-idiom
items := slices.Collect(query.AllUsers(ctx)) // 10M users → 10M heap allocations
for _, u := range items { /* ... */ }
// Idiomatic
for u := range query.AllUsers(ctx) {
/* process one at a time */
}
Use iter.Seq to its strength: lazy, streaming.
12.2 Forgetting iter.Pull cleanup¶
Without calling stop, the underlying goroutine leaks. Always defer stop().
12.3 Mutating the iterator's source mid-iteration¶
range captures the slice header at loop start. Appending may reallocate the backing array; the loop still iterates the original.
For most cases, don't mutate the source during iteration. If you must, use an index-based loop and decide explicitly how new elements are handled.
12.4 iter.Seq with side effects on yield-false¶
func Items() iter.Seq[Item] {
return func(yield func(Item) bool) {
for _, item := range allItems {
yield(item)
doSideEffect(item) // runs even when caller broke
}
}
}
The side effect runs after yield returned false. If that's wrong, check:
13. Performance notes¶
BenchmarkSliceRange-8 500000000 3 ns/op
BenchmarkIterSeq-8 300000000 5 ns/op
BenchmarkNextValue-8 200000000 8 ns/op
BenchmarkChannelRange-8 1000000 1200 ns/op
BenchmarkIterPull-8 5000000 350 ns/op
- Built-in slice range is the fastest.
iter.Seqis close (extra function call indirection).Next/Valueinterface is a bit slower (method calls).- Channel iteration is much slower (scheduler overhead).
iter.Pullis slow because it spawns a goroutine internally (in older implementations; future versions may improve).
For tight inner loops, prefer slice range. For composable iteration, iter.Seq is the right tool.
14. Tricky points¶
14.1 iter.Seq composition allocates closures¶
Each Map, Filter, Take returns a new iter.Seq, which is a function value capturing the underlying seq. The closure escapes; allocated on heap.
For a single iteration, this is fine (one closure per stage). For repeated iteration of the same pipeline, build it once.
14.2 The "leaked" generator goroutine¶
Even with iter.Pull's built-in stop(), if the underlying iterator spawns a goroutine (e.g., a channel-based producer), stop() must terminate it. Use context.Context for explicit lifetime.
14.3 for range over a function with no return¶
Works in Go 1.23+. The single-value form ignores the yielded value.
15. Test¶
Q1. What does this print?¶
Answer
`0` then `1`. The `if v == 2 { break }` exits before printing. The iterator's `yield(2)` returned false; the iterator stops.Q2. What's wrong?¶
Answer
If `ListUsers` returns `iter.Seq2[*User, error]`, the second value (error) is being ignored. Use `for u, err := range ...` and handle errors. If it's `iter.Seq[*User]`, errors might be exposed via a separate accessor — check the API.Q3. Implement Chain — concatenate two iterators.¶
Answer
First exhausts `a`, then `b`. Caller's `break` stops both.16. Cheat sheet¶
| Need | Solution |
|---|---|
| Walk a built-in collection | range |
| Custom iterator | iter.Seq[T] |
| Key-value iterator | iter.Seq2[K, V] |
| Compose: map/filter/take | Free functions returning iter.Seq[T] |
| Two iterators in lockstep | iter.Pull |
| Cleanup needed | Return iter.Seq plus cleanup func |
| Paginated API | iter.Seq2[T, error] |
| Concurrent producer | Channel with context-aware producer |
| Materialise | slices.Collect(seq) |
| Count | for range seq { n++ } |
| Forget | iter.Pull requires stop() always |