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
…
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 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:
- Goroutine leaks (if
Closeisn’t called). - Wrapping values (to pass both data and errors over a single channel).
selectstatements (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 (fs *IteratorStream) Recv() (int, error) {
if fs.done {
return 0, io.EOF
}
// Pull the next value from the iterator.
val, err, valid := fs.next()
// If valid is false, the iterator is exhausted.
if !valid {
fs.done = true
fs.stop() // Ensure cleanup happens
return 0, io.EOF
}
// If the iterator yielded an error, we return it and stop.
if err != nil {
fs.done = true
fs.stop()
return 0, err
}
return val, nil
}
func (fs *IteratorStream) Close() error {
fs.stop() // Tells the iterator to stop (yield returns false)
fs.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.