Awarded as the top customer success solution by Winning by Design - 2025

Practical Patterns for Go Iterators

A brief introduction to Go iterators, with helpful patterns for error handling and integration into streams

Preetam Jinka

By Preetam Jinka

Co-founder and Chief Architect

Dec 05, 2025

6 min read

I’ve been writing Go since 2013. In that time, the language has remained remarkably stable. The few major shifts that did happen, however, fundamentally changed how I write code day-to-day:

  • context.Context
  • Structured error wrapping (fmt.Errorf, %w)
  • Go modules
  • Generics (I don't introduce generic types daily, but I certainly consume them)

I knew Go 1.23 introduced iterators, but I didn't pay much attention to them until recently. I was forced to adapt when a library I rely on began exposing iterators in its public API.

It took me a while to really "get" them, but I can now see how they are a powerful addition to the Go developer's toolbox. If you’ve been avoiding iterators, here is a look at the helpful patterns that changed my mind.

The Basics: iter.Seq

The core concept is iter.Seq[V]. It looks intimidating at first because it uses generics, and relies on passing functions to functions. But the usage is actually very clean.

Instead of the caller pulling data (like rows.Next()), the iterator pushes data back to the loop via a yield function.

package main

import (
	"fmt"
	"iter"
)

// Fibonacci returns an iterator that generates numbers infinitely.
func Fibonacci() iter.Seq[int] {
	return func(yield func(int) bool) {
		a, b := 0, 1
		for {
			// yield pushes the value back to the loop.
			// If yield returns false, the loop has broken/quit.
			if !yield(a) {
				return
			}
			a, b = b, a+b
		}
	}
}

func main() {
	// The usage feels just like a slice or map.
	for f := range Fibonacci() {
		if f > 100 {
			break
		}
		fmt.Println(f)
	}
}

This is a generator function that returns an iter.Seq[int]. The yield function pushes the value back to the loop; if yield returns false, it means the consumer has broken out of the loop and the iterator should stop.

The Real World: Handling Errors (iter.Seq2)

For most production code, a simple sequence of values isn't enough. We are usually iterating over rows, API responses, or file streams where things can break.

In the old days, we might have used a struct with an .Err() method (like sql.Rows). With iterators, we use iter.Seq2[K, V]. This allows for range to accept two variables. By convention, we use the second variable for the error.

(There is a great write-up on this error handling pattern over at Bitfield Consulting).

// FibWithErr returns a Seq2[int, error]. 
// The first return value is the number; the second is an error.
func FibWithErr(limit int) iter.Seq2[int, error] {
	return func(yield func(int, error) bool) {
		a, b := 0, 1
		for i := 0; i < limit; i++ {
			if !yield(a, nil) {
				return
			}
			a, b = b, a+b
		}
		// Signal an error at the end of the sequence
		yield(0, errors.New("Fibonacci sequence limit reached"))
	}
}

func main() {
	for v, err := range FibWithErr(10) {
		if err != nil {
			fmt.Println("Error:", err)
			break
		}
		fmt.Println(v)
	}
}

The Pattern: Bridging Iterators to Streams

This is the specific problem that tripped me up. In our codebase (which is about 3 years old), we have several stream interfaces backed by various other libraries. Typically, these interfaces expose a "pull" mechanism with a Recv and Close method:

type Stream interface {
	Recv() (int, error)
	Close() error
}

A new library we adopted exposes an iterator interface. The question was: how do we bridge the two?

If you are implementing a Recv-style interface but your internal logic uses modern iterators, you have an impedance mismatch. You cannot easily pause a for loop inside a yield function to wait for a Recv call.

The "Old Way": The Channel Adapter

My initial instinct was to create a wrapper using channels to push values from the iterator into a channel that the stream interface reads from.

However, if you've done this before, you know it is messy. You have to handle:

  1. Goroutine leaks (if Close isn't called).
  2. Wrapping values (to pass both data and errors over a single channel).
  3. select statements (to handle cancellation while blocking on send).

Here is what that "bad" pattern looks like:

// IteratorStreamViaChannel is the "Old Way" of adapting an iterator.
// DO NOT DO THIS.
type IteratorStreamViaChannel struct {
	ch   chan item 
	quit chan struct{} 
}

type item struct {
	val int
	err error
}

func NewChanStream(seq iter.Seq2[int, error]) *IteratorStreamViaChannel {
	s := &IteratorStreamViaChannel{
		ch:   make(chan item), 
		quit: make(chan struct{}),
	}

	// Spin up a goroutine. Now we have concurrency complexity.
	go func() {
		defer close(s.ch) 

		seq(func(v int, err error) bool {
			select {
			case s.ch <- item{v, err}:
				return true
			case <-s.quit:
				// Consumer called Close(), stop the iterator to prevent leaks.
				return false
			}
		})
	}()

	return s
}

We are paying the cost of channel locks and the scheduler just to iterate over a list. We can do better.

The "New Way": iter.Pull

The better approach is to use iter.Pull. This function allows you to flip the control flow back to the caller. It turns a "Push" iterator into a "Pull" function (next()).

Here is a robust pattern for wrapping an iter.Seq2 into a legacy Recv() interface without using channels.

package main

import (
	"errors"
	"fmt"
	"io"
	"iter"
)

// IteratorStream wraps a Seq2 iterator into a Stream.
type IteratorStream struct {
	next func() (int, error, bool) // The "pull" function provided by runtime
	stop func()                    // Cleanup function to close the iterator
	done bool
}

// NewIteratorStream creates the adapter.
func NewIteratorStream(seq iter.Seq2[int, error]) *IteratorStream {
	// iter.Pull2 converts the push-iterator into a pull-function.
	next, stop := iter.Pull2(seq)
	return &IteratorStream{
		next: next,
		stop: stop,
	}
}

func (s *IteratorStream) Recv() (int, error) {
	if s.done {
		return 0, io.EOF
	}

	// Pull the next value from the iterator.
	val, err, valid := s.next()

	// If valid is false, the iterator is exhausted.
	if !valid {
		s.done = true
		s.stop() // Ensure cleanup happens
		return 0, io.EOF
	}

	// If the iterator yielded an error, we return it and stop.
	if err != nil {
		s.done = true
		s.stop()
		return 0, err
	}

	return val, nil
}

func (fs *IteratorStream) Close() error {
	s.stop() // Tells the iterator to stop (yield returns false)
	s.done = true
	return nil
}

Wait, how does Pull actually work?

If you are a veteran like me, you are probably assuming iter.Pull just spins up a goroutine and uses a channel to synchronize the data, similar to the bad example above.

You might be surprised to learn that it does not use channels.

The Go runtime actually added a coroutine implementation to support this. It pauses the stack and switches control flow efficiently. I highly recommend reading Coroutines in Go if you are curious about the internals.

Conclusion

I was skeptical at first, but standardizing on iter.Seq and iter.Seq2 has made our looping logic cleaner and more composable. The addition of iter.Pull is the killer feature—it lets us adopt these new patterns internally without breaking our existing interfaces.

It’s definitely a pattern worth adding to your style guide.