Skip to content

Go Iterators in 2026: The range-over-func Pattern I Actually Use

Go Iterators in 2026: The range-over-func Pattern I Actually Use

Short version for the impatient: if you’re on Go 1.23 or newer, you can hand a plain function to for range and stop building slices you throw away two lines later. If you want to know why that’s worth doing, stick around.

I spent a genuinely dumb afternoon last week. I was writing a helper that walks a paginated API, and I did the thing I’ve done a hundred times: allocate a slice, append every record to it, return the slice, let the caller loop over it once, then watch the garbage collector sweep up a few thousand structs nobody needed after the first pass. It works. It’s also wasteful, and I only noticed because a profiler flagged the allocation. Range-over-func has been in the language since Go 1.23, and I still hadn’t moved half my code to it. So I fixed that. Here’s what actually clicked for me.

The slice I kept allocating for no reason

Here’s the old shape. You’ve written this. I’ve written it more times than I’d like to admit.

func FetchAll(client *Client) ([]Record, error) {
    var out []Record
    page := 0
    for {
        recs, hasMore, err := client.Page(page)
        if err != nil {
            return nil, err
        }
        out = append(out, recs...)
        if !hasMore {
            break
        }
        page++
    }
    return out, nil
}

// caller
recs, err := FetchAll(client)
if err != nil {
    return err
}
for _, r := range recs {
    process(r)
}

The entire result set sits in memory even though the caller touches one record at a time. If the API hands back fifty thousand rows, you’re holding fifty thousand rows. And if process bails on the third one, you paid to fetch and buffer the other 49,997 for nothing. For years the workaround was to accept a callback, which reads fine until you want to break, or continue, or return an error from inside the loop. Then you’re inventing sentinel errors and it gets ugly fast.

What range-over-func actually is

The idea is small once it lands. Instead of returning data, you return a function that produces data, and for range knows how to drive it. The standard library added an iter package with two types for this, documented in the iter package reference:

type Seq[V any]     func(yield func(V) bool)
type Seq2[K, V any] func(yield func(K, V) bool)

That’s the whole vocabulary. A Seq[V] is a function that takes a yield callback. You call yield(v) once per value. When yield returns false, the consumer is done and you stop. The Go team walks through the design reasoning in their post on range over function types, and it’s worth reading once, because the yield return value is the part people skip and then get wrong.

The mental model that finally made it stick for me: you’re not returning values, you’re returning the loop body’s driver. Your iterator function is in charge of the “for” part, and yield is the caller’s loop body. Every time you call yield, you’re running one iteration of the caller’s loop. The boolean it hands back is the caller saying “keep going” or “I’m out.” That inversion feels strange for about ten minutes and then it’s second nature. It’s the same push model channels give you, except there’s no goroutine, no channel allocation, and no risk of leaking a producer that’s blocked forever on a send nobody’s reading.

Rewriting a real iterator

Here’s the paginated fetch again, this time as an iterator. Notice the caller barely changes, but nothing gets buffered.

import "iter"

func AllRecords(client *Client) iter.Seq2[Record, error] {
    return func(yield func(Record, error) bool) {
        page := 0
        for {
            recs, hasMore, err := client.Page(page)
            if err != nil {
                yield(Record{}, err)
                return
            }
            for _, r := range recs {
                if !yield(r, nil) {
                    return
                }
            }
            if !hasMore {
                return
            }
            page++
        }
    }
}

// caller
for r, err := range AllRecords(client) {
    if err != nil {
        return err
    }
    process(r)
}

One record is alive at a time. If process decides to break, the range loop stops calling yield, yield returns false, and the iterator returns instead of fetching the next page. The error handling reads like normal Go now, no sentinel values, no callback that can’t signal “stop.” I moved three helpers over to this shape and deleted more code than I added, which is usually a sign I was doing something silly before.

The part that surprised me was testing. I’d assumed iterators would be annoying to test, but they’re not, because a for range in the test is exactly how production code consumes them. You loop, you collect into a slice with slices.Collect, and you assert on that slice. There’s no fake callback to wire up and no interface to mock. If anything, the tests got shorter, because the iterator and its consumer speak the same built-in language.

Seq2, early exit, and the yield contract

The one rule that trips people up: after yield returns false, you must not call it again. If you do, the runtime panics, and honestly that panic saved me once by catching a loop where I ignored the return value. So the pattern is always if !yield(x) { return }, not a bare yield(x). Get that habit early.

Seq2 is the two-value version, and it’s not only for key/value maps. The (Record, error) pairing above is the idiom I reach for most, because it lets an iterator report a failure mid-stream without a separate channel or a stashed error field. If you only need values and never errors, use plain Seq[V] and keep it simple.

Composition is the part I didn’t expect to enjoy. Because an iterator is just a function, you can wrap one in another. A Filter that drops records failing a predicate is about six lines, and it streams, so filtering a huge source still holds one item at a time.

func Filter[V any](src iter.Seq[V], keep func(V) bool) iter.Seq[V] {
    return func(yield func(V) bool) {
        for v := range src {
            if keep(v) && !yield(v) {
                return
            }
        }
    }
}

This is the same instinct I wrote about in my post on where I actually use Go generics: the type parameter earns its place when it lets you write one honest helper instead of five copy-pasted ones.

When I don’t reach for an iterator

I’m not going to pretend this belongs everywhere. If the data already fits comfortably in a slice and the caller wants random access or a length, hand back the slice. An iterator gives you neither len() nor indexing, and forcing callers to collect it back into a slice just to count it is worse than returning the slice in the first place. The Go 1.23 release notes cover the standard library helpers that landed alongside this, including slices.Collect and maps.Keys, and they’re documented in the Go 1.23 release notes. Reach for iterators when the source is large, lazy, streaming, or expensive to produce. Keep slices for small, eager, already-in-memory data. That’s the whole heuristic.

The other honest caveat: there’s a tiny per-call overhead to the yield indirection. For a hot loop over an in-memory slice of ints, a plain for i := range s is faster and you should just use it. Benchmark before you convert something on the hot path. I converted a parser inner loop once, felt clever, then measured and quietly reverted it.

What to try this week

Find one function in your codebase that returns a slice the caller ranges over exactly once and never indexes. That’s your candidate. Convert it to an iter.Seq or iter.Seq2, run your tests, and check the allocation count with go test -bench . -benchmem. If the allocations drop and the tests stay green, you’ve found the pattern. If you build data pipelines like this a lot, that’s the kind of plumbing I end up doing in most of my backend work, and iterators have quietly become the default shape for the streaming parts.