Skip to content

Go 1.23 Iterators: The Hand-Rolled Boilerplate I Stopped Writing

Go 1.23 Iterators: The Hand-Rolled Boilerplate I Stopped Writing

I’ve been writing Go since the 1.5 days, and for most of that time my answer to “how do I iterate over this thing” was the same tired dance: write a method that returns a slice, or hand-roll a Next()/Value() pair, or pass a callback into a Walk function and live with the awkward early-exit problem. None of it was bad. All of it was a little annoying.

Then Go 1.23 shipped range-over-func and the iter package, and I spent a confused weekend trying to figure out whether it was a real upgrade or just a new shape for the same problem. Short answer: it’s a real upgrade, but only for a specific slice of code. I deleted maybe 400 lines across two services and added back about 60. That’s the post.

If you want the official spec, the Go 1.23 release notes cover the language change and the iter package docs cover the standard interface. This is the part of the story they don’t tell you.

What iterating in Go used to look like

Here’s the shape I wrote a hundred times. Say I have a paginated API client and I want to walk every result without loading them all into memory:

// pre-1.23: callback-with-error
func (c *Client) WalkOrders(ctx context.Context, fn func(Order) error) error {
    cursor := ""
    for {
        page, err := c.fetchPage(ctx, cursor)
        if err != nil {
            return err
        }
        for _, o := range page.Items {
            if err := fn(o); err != nil {
                return err
            }
        }
        if page.NextCursor == "" {
            return nil
        }
        cursor = page.NextCursor
    }
}

The caller side wasn’t terrible, but you couldn’t just break out of it. You had to invent a sentinel error or thread a bool through. And if you wanted to compose two of these together (paginated orders for paginated customers, say), the type signatures got ugly fast.

The other common shape was a Next()/Value() cursor type, which works fine until you forget to call Close() and leak a database connection. I’ve shipped that bug more than once. I’m not proud of it.

What range-over-func actually changed

The new pattern is that any function with the signature func(yield func(V) bool) is rangeable. The standard names live in iter.Seq[V] (one value) and iter.Seq2[K, V] (two values, like a map). Same paginated walker, rewritten:

// 1.23+: an iter.Seq2 that yields (order, error)
func (c *Client) Orders(ctx context.Context) iter.Seq2[Order, error] {
    return func(yield func(Order, error) bool) {
        cursor := ""
        for {
            page, err := c.fetchPage(ctx, cursor)
            if err != nil {
                yield(Order{}, err)
                return
            }
            for _, o := range page.Items {
                if !yield(o, nil) {
                    return
                }
            }
            if page.NextCursor == "" {
                return
            }
            cursor = page.NextCursor
        }
    }
}

And the caller:

for order, err := range client.Orders(ctx) {
    if err != nil {
        return err
    }
    if order.Total > threshold {
        process(order)
        break // and this actually works
    }
}

Three things matter here. break works because the yield call returns false and the producer cooperates by returning. Errors travel inline as a second value, which is a much better fit for Go’s error-handling habits than the callback approach. And there’s no Close() to forget, because the producer function exits when the loop exits.

I’ve written about how I actually reach for Go generics, and iter.Seq is one of the cleanest payoffs I’ve found. Generics let you write Map, Filter, and Take once and have them work on any iterator. That’s the thing that finally made me stop reaching for slices of intermediate values.

Where it actually saves me code

Four places, ranked by how much I gained.

Streaming over a database cursor. I have a Postgres scan loop that used to look like a state machine. Now it’s an iter.Seq2[Row, error] and the caller just ranges. defer rows.Close() lives inside the iterator, so nobody leaks anything.

func QueryRows[T any](ctx context.Context, db *sql.DB, sql string, scan func(*sql.Rows) (T, error), args ...any) iter.Seq2[T, error] {
    return func(yield func(T, error) bool) {
        rows, err := db.QueryContext(ctx, sql, args...)
        if err != nil {
            yield(*new(T), err)
            return
        }
        defer rows.Close()
        for rows.Next() {
            v, err := scan(rows)
            if !yield(v, err) {
                return
            }
            if err != nil {
                return
            }
        }
        if err := rows.Err(); err != nil {
            yield(*new(T), err)
        }
    }
}

Composing transformations. With a few helpers I rarely allocate intermediate slices anymore:

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) && !yield(v) {
                return
            }
        }
    }
}

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 || !yield(v) {
                return
            }
            i++
        }
    }
}

Tree and graph traversal. Yielding nodes from a recursive walk used to mean either a callback (with the early-exit problem) or building a giant slice. Now I yield from inside the recursion and the caller can stop whenever.

Pagination on third-party APIs. This is the biggest day-to-day win. Stripe, GitHub, our internal job queue, all wrapped as iter.Seq2[T, error]. The calling code reads like the API is local.

The footguns no one tells you about

Three gotchas I hit in the first month, none of which are obvious from the release notes.

The yield return value isn’t optional. If you don’t check it and return when it’s false, your iterator keeps producing values into a loop that’s already broken. In a pure CPU loop that’s wasted work. In an HTTP-paginated iterator, that’s real money. Always do if !yield(v) { return }.

Iterators are functions, not state machines. You can range over the same iter.Seq twice and the producer runs twice. That’s almost always what you want, but if you’re iterating a database cursor and the caller ranges twice by accident, you’ll execute the query twice. I added a Once wrapper that panics on second iteration.

iter.Pull exists when you really need state. Sometimes you want to peek the next value, or interleave two iterators, or otherwise turn iter.Seq into something that feels like the old Next() cursor. iter.Pull does exactly that and returns a next, stop pair. The catch: you have to call stop. The compiler will not remind you. I aliased it to defer stop() in every call site as a habit.

There’s a fourth one I’ll mention briefly. Stack traces inside iterator functions are weirder than normal because the yield call is essentially a coroutine resume. If you’re debugging, prefer adding logs at the yield boundary rather than relying on the stack you see at the panic site.

When I still write a plain for loop

This is the bit I want to be honest about. Range-over-func is not always the answer.

If the producer is in-memory and finite, a slice is still fine. iter.Seq over a 12-element config slice is performative; just use range items. The new pattern earns its keep when the producer is lazy, when iteration can fail, or when you want composability. For a []string of usernames, write the loop.

If the consumer needs random access or to iterate multiple times cheaply, materialize to a slice. slices.Collect(seq) is one line and gives you the array you wanted.

If you’re inside a hot path where allocation matters and you’ve measured, the closure indirection in iter.Seq does have a real cost compared to an inlined loop. It’s small, but it’s not zero. Profile before you decide.

What I’d do this week if I were you

Pick one place in your codebase that currently uses a callback-style Walk or a Next()/Value() cursor. Rewrite it as an iter.Seq2[T, error] and update the two or three call sites. Don’t try to migrate everything at once. The win compounds: every callback you delete is one less custom signature your teammates have to remember, and iter.Seq2[Foo, error] becomes the shared vocabulary.

If you want to see more of the kind of backend work I do with Go, Rust, and Postgres, my project work is on abrarqasim.com. And if you’ve found a clever use of iter.Pull I haven’t, I’d actually like to hear about it. The use cases for it are narrower than I expected, and I’m still figuring out where it earns its weight.