Sinclair Target

Error Handling with Iterators in Go

Jul 14, 2025

Iterators are a relatively new feature of Go, added to the language only last year. Shortly after they were added, the Go team published a blog post explaining the design choices they made in implementing iterators and offering some examples of how to use them. Curiously absent from the blog post is any mention of error handling.

In simple cases, like iterating over a collection of objects already in memory, error handling isn’t necessary. But iterators are also useful for doing things like reading chunks of data from disk, handling paginated results from an API, or trawling through rows fetched from a database. In cases like these, the underlying operation could fail each time we advance the iterator, so we have to consider error handling.

According to this Github discussion, error handling was left out of the documentation for iterators on purpose, apparently because the Go team wanted to see if a convention would arise organically among Go users. Perhaps the Go team just couldn’t agree among themselves which would be the best approach. Given that there is no official documentation on error handling with iterators, and that no organic convention has yet been established, Go users are left to pick between several possible patterns on their own.

Perhaps the most popular pattern—the one recommended on Reddit and the one Claude AI suggested to me when I asked its opinion—is using iter.Seq2 to handle errors. I’ve experimented with a few alternatives and have concluded that iter.Seq2 is not the best way to handle iterator errors. Let’s first talk about why people gravitate toward iter.Seq2 and then look at a better option: using an error callback.

The iter.Seq2 Approach

Using iter.Seq2 to handle errors was actually suggested in the original iterator proposal. The suggestion was scrubbed from the inline documentation in the iter package before the package was merged into Go—presumably because someone didn’t think it was a good idea.

Using iter.Seq2 to handle errors looks like this. On each step of our iteration, we yield two values, the second being an error value indicating whether an error occurred at that step in the iteration. In this contrived example, we’re iterating over colors and want to handle a missing color (represented using an empty string) as an error:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
func colors() iter.Seq2[string, error] {
    colors := []string{ "blue", "yellow", "", "green" }

    return func(yield func(string, error) bool) {
        for _, color := range colors {
            if color == "" {
                err := errors.New("missing color")
                yield("", err)
                return
            }

            if !yield(color, nil) {
                return
            }
        }
    }
}

We can consume such an iterator like this:

1
2
3
4
5
6
7
8
for color, err := range colors() {
    if err != nil {
        fmt.Printf("error: %s\n", err)
        break
    }

    fmt.Printf("got color: %s\n", color)
}

Essentially what we are doing is turning a sequence of values into a sequence of (value, error) tuples.

The best thing going for this approach is that it looks a lot like how you would usually handle errors in Go. On each step of the for range loop we check the second value for an error. This is just like calling a function that might return a second error value or nil and checking that.

This approach also yields separate errors on each step of the iteration, giving you an opportunity to handle the error and continue iterating. That’s not what we need in this example, but if you wanted to you could report the error and still print the remaining colors.

Putting these benefits aside, there are a few problems with using iter.Seq2. The biggest problem is that it creates two species of iterator, regular iterators and what we might call “error-aware” iterators. You cannot write functions that work with both simple iter.Seq iterators and iter.Seq2 iterators where the second element is an error. Other languages with iterators implement, in their standard libraries, large numbers of “iterator adaptors,” which are handy methods that perform common transformations (like filtering and mapping) on iterators. Using iter.Seq2 to handle errors means you need a parallel set of iterator adaptors for “error-aware” iterators.

(There is a library called go-softwarelab/common demonstrating this problem. It implements the same adaptors three times over for iter.Seq, iter.Seq2, and a type the library calls seqerr, which is an iter.Seq2 with an error as the second element.)

Another problem with the iter.Seq2 pattern is that the standard library doesn’t seem to anticipate it. In fact, the standard library doesn’t offer much support for using iter.Seq2 at all, at least not compared with iter.Seq. In the slices package, there are some useful functions for turning a slice into an iterator (Values()) and vice versa (Collect()). But there are no equivalent functions working with iter.Seq2. The only place iter.Seq2 seems to come up is iterating over key-value pairs from a map or index-value pairs from a slice.

As was recently pointed out to me at a talk I gave to the Boston Go meetup group, this makes sense when you consider the type definition of iter.Seq2. The type parameter names (K and V) suggest that iter.Seq2 is intended for key-value pairs and nothing else:

1
2
3
4
type (
    Seq[V any]     func(yield func(V) bool)
    Seq2[K, V any] func(yield func(K, V) bool)
)

This naming makes the iter.Seq2 pattern for error handling feel like an abuse of a language feature that was not meant to be used that way.

Finally, another problem with the iter.Seq2 pattern is that it doesn’t handle every kind of error that might occur during iteration through a sequence. Yes, it gives us a way to handle errors that might occur on each step through the sequence, but it’s possible we encounter an error either before iteration (while setting up) or afterward (while cleaning up). These errors don’t correspond to a particular value yielded in the sequence so it doesn’t make sense to pair them with one. We could yield a cleanup error along with a nil value at the end of the sequence, but then what happens if the consuming code doesn’t iterate all the way to the end of the sequence? Ideally, we’d like to use an error handling pattern that can handle every type of error that might arise while using an iterator.

Some Alternatives

There are a few other things you could do rather than use iter.Seq2.

Although Go does not have proper sum types, you could create a Result type similar to what exists in Rust and then yield that on each step through an iterator. The consuming code would then check whether or not the Result object contains an error before trying to access the actual yielded value. While this works, it does not feel idiomatic in a language known for not using a Result type to model errors returned from functions. It also does not handle the set up or clean up errors I mentioned above.

Alternatively, you could approach error handling the same way errors are handled by bufio.Scanner in the standard library. That type does not use Go 1.23’s iterators but conceptually is still an iterator over tokens read from an input stream. When bufio.Scanner encounters an error, rather than yielding the error as part of the sequence of tokens, it simply sets an internal error value that can be retrieved by calling an Err() method. You could do something similar with Go 1.23’s iterators by setting an error on whichever object created the iterator. But it would be the caller’s responsibility to remember to check that value.

Using an Error Callback Instead

My preferred pattern is to have any function that returns an iterator also return a second value, called finish, which is a callback you can use to retrieve an error value. You don’t have to name this callback finish. That’s just my preferred name; other people have suggested errf. I’ll explain exactly how this works in a second, but first let me give you a flavor for what this would look like on the consuming side, adapting our colors example from above:

1
2
3
4
5
6
7
8
9
seq, finish := colors()
for color, err := range seq {
    fmt.Printf("got color: %s\n", color)
}

err := finish()
if err != nil {
    fmt.Printf("error: %s\n", err)
}

In this example, we print all the colors we encounter before we get to the error. When we do hit the error, the iterator stops yielding values. So we only print “blue” and “yellow,” the first two colors. At that point we exit the for range loop over the iterator and call the error callback. The error we retrieve, which we print to the console, contains information about the error that occurred while iterating.

Here is what the colors() function would look like using this pattern:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
func colors() (iter.Seq[string], func() error) {
    colors := []string{ "blue", "yellow", "", "green" }

    var iterErr error
    seq := func(yield func(string) bool) {
        for _, color := range colors {
            if color == "" {
                iterErr = errors.New("missing color")
                return
            }

            if !yield(color) {
                return
            }
        }
    }

    finish := func() error {
        return iterErr
    }

    return seq, finish
}

In the above, we create a variable called iterErr that holds any error we might encounter while setting up, tearing down, or processing our iteration. The variable lives in the scope of the colors() function but continues to exist even after the colors() function returns because it gets captured by both the seq and finish closures. Initially, when colors() returns, iterErr is nil. When later the iterator is consumed and the body of seq is executed, iterErr might get set to a value. We give the caller the finish callback to retrieve that value.

We have to use a callback to retrieve the error and can’t just return iterErr from colors() because colors() always returns before the iterator is even run. At that point, we haven’t yet had a chance to encounter an error and set iterErr.

There are many things I like about handling iterator errors this way. The thing I like the most is that we now have a function returning a simple iter.Seq[string], which means we can use it with the utility functions in slices (Values() and Collects()) or any iter.Seq iterator adaptor.

The finish callback also gives us an opportunity to handle every error that might be triggered within our iterator, even those that might arise before or after iteration. In the above example, an error is only triggered during iteration. But let’s add some set up and clean up code to demonstrate how these other errors could be handled too:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
func colors() (iter.Seq[string], func() error) {
    var iterErr error
    seq := func(yield func(string) bool) {
        // -- set up --
        colors, err := db.LoadColors()
        if err != nil {
            iterErr = err
            return // sequence will be empty
        }

        for _, color := range colors {
            if color == "" {
                iterErr = errors.New("missing color")
                return
            }

            if !yield(color) {
                return
            }
        }

        // -- clean up --
        iterErr = db.Close()
    }

    finish := func() error {
        return iterErr
    }

    return seq, finish
}

Here, we imagine that we are retrieving the colors from some kind of database connection that has to be closed after we use it. We might encounter an error loading the colors from the database and we might encounter an error closing the database connection. Though the body of seq gets a little more complicated, these errors can all be handled the same we handle an error during iteration—we set iterErr so it can later be retrieved with finish.

Unlike using a Result type, an error callback seems more Go-like. The function returning the iterator (in our example, colors()), returns the iterator and a second value for error handling. Yes, this second value isn’t a simple error, but it leads to error-checking code that looks similar to the error-checking code we normally write in Go.

We will also get a compiler error if we don’t remember to call the callback (because we’ve created a variable that we’ve never used). This is a major advantage over setting an error value on some object that later has to retrieved with an Err() method. If we forget to do that, the compiler has no way to warn us.

I should clarify that this approach is best for iterators that can fail in a way that invalidates the sequence. This is often true of single-use iterators that consume from a stream connected to I/O of some kind, be that a file, a network socket, or something else. If something goes wrong, we can’t get any more values, so it makes sense to end the sequence there and set a single error. If instead we’re iterating over a sequence where each individual item might have its own accompanying error—and when those errors don’t affect other items in the sequence—it would be more appropriate to use iter.Seq2.

Using an error callback can be more verbose than the iter.Seq2 approach. I haven’t yet figured out how to use error callbacks to handle errors from a pipeline of iterators without using a giant stack of repetitive defer blocks. But Go is a language that prefers verbosity and being explicit over magic and concision. A better approach to error handling with iterators might well come along, but so far using error callbacks feels to me like the most idiomatic option.