Go iterators (range-over-func): the yield contract and the traps

I wanted to turn a pagination helper into a "clean" iterator with Go 1.23's range syntax. Thirty seconds later: panic: range function continued iteration after function call returned false. The kind of message that says nothing until you understand the underlying contract. range-over-func iterators are elegant, but they shift part of the responsibility from the compiler to you — and that's where it breaks.

Here's how they actually work, and the three traps that turn a "clean" iterator into a production bug: the yield contract, cleanup on break, and the iter.Pull leak.

An iterator is a function that takes a yield

Since Go 1.23, you can range over a function. The iter package standardizes two signatures: iter.Seq[V] (one value) and iter.Seq2[K, V] (two, like key/value). An iterator is a function that receives a yield and calls it for each element:

// iter.Seq[int] = func(yield func(int) bool)
func Count(n int) iter.Seq[int] {
    return func(yield func(int) bool) {
        for i := 0; i < n; i++ {
            if !yield(i) {
                return // the consumer stopped → we stop
            }
        }
    }
}

// caller side: the range syntax hides the whole mechanism
for i := range Count(3) {
    fmt.Println(i) // 0, 1, 2
}

When you write for i := range Count(3), the compiler turns your loop body into a yield closure that it passes to the iterator. Each yield(i) runs one iteration of your loop. The return value of yield is the key to everything: true = keep going, false = the consumer did a break or a return.

The yield contract: honor the false, always

This is the trap, the one behind my panic. When yield returns false, your iterator must stop immediately and never call yield again. If you keep going, the runtime panics to stop you from producing into a consumer that's no longer listening.

// ❌ ignores yield's return → panic on the consumer's first break
func Bad(n int) iter.Seq[int] {
    return func(yield func(int) bool) {
        for i := 0; i < n; i++ {
            yield(i) // we NEVER look at the returned bool
        }
    }
}

// ✅ honors the contract
func Good(n int) iter.Seq[int] {
    return func(yield func(int) bool) {
        for i := 0; i < n; i++ {
            if !yield(i) {
                return
            }
        }
    }
}

The rule is mechanical: every call to yield must be guarded by an if !yield(...) { return }. If you produce in several branches, each one must honor the contract. That's the price of the sugar syntax: the compiler can't check it for you, so it checks it at runtime — brutally.

Cleanup when the consumer breaks

An iterator that holds a resource — a file, a connection, a DB cursor — must release it even if the consumer stops halfway. And since a break on the caller side makes yield return false then exits your function, a simple defer inside the iterator is enough — as long as you write it:

func Lines(path string) iter.Seq[string] {
    return func(yield func(string) bool) {
        f, err := os.Open(path)
        if err != nil {
            return
        }
        defer f.Close() // ✅ runs even if the consumer breaks

        sc := bufio.NewScanner(f)
        for sc.Scan() {
            if !yield(sc.Text()) {
                return // defer f.Close() fires here too
            }
        }
    }
}

// the defer closes the file whether the loop finishes or stops at line 2
for line := range Lines("big.log") {
    if strings.Contains(line, "FATAL") {
        break
    }
}

The classic mistake is opening the resource outside the iterator function (when building the iter.Seq), where the defer no longer covers the break case. Open the resource inside the closure, close it with defer right after. Same cancellation discipline as not leaking a goroutine: plan the early exit from the design.

iter.Pull: pulling on demand, and never forgetting stop()

range is a "push" model: the iterator pushes values. Sometimes you want to "pull": advance two sequences in parallel, or consume one value at a time on a decision. iter.Pull converts a push iterator into two functions — next() and stop():

next, stop := iter.Pull(Count(1000))
defer stop() // ✅ MANDATORY — otherwise a leak

for {
    v, ok := next()
    if !ok {
        break
    }
    if v == 42 {
        break // early stop: stop() (via defer) releases the iterator
    }
}

iter.Pull runs the iterator in a dedicated goroutine. If you don't consume to the end and you don't call stop(), that goroutine stays blocked forever: a pure goroutine leak. The reflex is invariable: next, stop := iter.Pull(...) immediately followed by defer stop(). No exception.

When NOT to write an iterator

The hype pushes you to turn everything into an iter.Seq. That's a mistake. A range-over-func has a cost: indirect function calls, closures, inlining limits the compiler doesn't always cross. For a small collection already in memory, a slice is simpler and faster.

// ❌ pointless: the data fits in memory, the iterator only adds overhead
func Names() iter.Seq[string] { /* ... */ }

// ✅ return a slice: simpler to call, to test, to compose
func Names() []string { return []string{"a", "b", "c"} }

Iterators shine at what a slice does badly: lazy sequences (computed on demand), infinite ones (a counter, a stream), expensive ones (API pagination, reading a big file line by line), or composed ones (chained filters and maps without materializing the intermediates). If none of these apply, return a slice. It's the Go spirit of simplicity by default: the cleverest feature isn't always the right one.

Conclusion

range-over-func iterators are a real addition to the language, but they introduce a contract the compiler only enforces at runtime: honor yield's false, close resources inside the closure, call stop() on an iter.Pull. Three rules, three traps. Once internalized, they become automatic.

The question to ask before writing one isn't "can I?" but "is the sequence lazy, infinite, expensive or composed?". If yes, the iterator is elegant and justified. If not, you're adding complexity and overhead to return a slice — and a slice never panics.

Comments (0)