DEV Community

Cover image for Go's range-over-func: 4 Footguns the Compiler Won't Warn You About
Gabriel Anhaia
Gabriel Anhaia

Posted on

Go's range-over-func: 4 Footguns the Compiler Won't Warn You About


Range-over-func shipped in Go 1.23. The standard library has been quietly ranging over iter.Seq and iter.Seq2 ever since, and most teams have absorbed the basics. You write a function that takes a yield func(T) bool, you call yield in a loop, you return when yield says stop. Two screens of documentation and a curious afternoon.

The part that is not fine is what happens when you push past the textbook example. The compiler will accept code that looks reasonable to a human and then panic at runtime with "range function continued iteration after function for loop body returned false". There is no vet rule that catches it. The type system has no concept of yield's lifetime. Yield is a closure, and Go closures do not carry contracts.

Iterators are fine. The misuse is the issue. Four shapes show up over and over in real code, none of them flagged by the compiler, all of them production-grade time bombs.

Footgun 1: Storing the yield closure

Every developer who likes functional patterns wants to treat yield as a first-class value. Save it. Pass it to a helper. Call it later. It is just a function.

Here is the minimal repro. A small batching helper captures yield once and replays it from inside a callback registered elsewhere.

package main

import (
    "fmt"
    "iter"
)

type Batcher struct {
    yield func(int) bool
}

func (b *Batcher) Set(y func(int) bool) {
    b.yield = y
}

func (b *Batcher) Flush(items []int) {
    for _, v := range items {
        if !b.yield(v) {
            return
        }
    }
}

func produce(b *Batcher) iter.Seq[int] {
    return func(yield func(int) bool) {
        b.Set(yield)
        // iterator function returns immediately;
        // yield is now stored on the Batcher
    }
}

func main() {
    b := &Batcher{}
    for v := range produce(b) {
        fmt.Println(v)
    }
    b.Flush([]int{1, 2, 3}) // boom
}
Enter fullscreen mode Exit fullscreen mode

What the compiler says: nothing. go build is happy. go vet is happy. There is no analyzer in the standard distribution that flags this.

What the runtime does: panic with "range function continued iteration after whole loop exit". The runtime tracks whether the iterator function has returned, and once it has, every subsequent yield is a contract violation. The check is in the state machine the compiler synthesizes around your range loop. You do not see it. It runs anyway. (go101.org, Some Facts About Go Iterators, March 2025)

The fix is to not treat yield as a value you own. Yield is alive for the duration of the iterator function, and the iterator function is alive for the duration of the for-range statement. If you need to drive consumption from outside that scope, use iter.Pull:

next, stop := iter.Pull(produce())
defer stop()
for {
    v, ok := next()
    if !ok {
        break
    }
    fmt.Println(v)
}
Enter fullscreen mode Exit fullscreen mode

iter.Pull runs the push-style iterator on a separate goroutine and gives you a synchronous next() you can call from anywhere. It exists because the yield closure is not safe to hoist out of its frame. (Go blog, Range Over Function Types)

Footgun 2: Calling yield after the loop body broke

More subtle and more common. You write an iterator that does bookkeeping after each yield call. Flushing a buffer, advancing a cursor, emitting a follow-up event. Break in the loop body returns false from yield, you check the return value, and then your bookkeeping code runs one more yield before the function returns.

func Pairs(items []int) iter.Seq[[2]int] {
    return func(yield func([2]int) bool) {
        for i := 0; i+1 < len(items); i += 2 {
            ok := yield([2]int{items[i], items[i+1]})
            // small "tail" emit for the partial pair
            yield([2]int{items[i+1], -1})
            if !ok {
                return
            }
        }
    }
}

func main() {
    for p := range Pairs([]int{1, 2, 3, 4, 5, 6}) {
        if p[0] == 3 {
            break
        }
        fmt.Println(p)
    }
}
Enter fullscreen mode Exit fullscreen mode

The intent is "emit the pair, then a tail marker, then check whether we should continue." Read it again with the contract in mind.

The compiler is fine with this. The structure is legal. Yield is called twice per iteration, and that is something iterators do constantly. There is no way for static analysis to know which of the two yield calls is the one that corresponds to the break.

The runtime is not fine. As soon as the loop body executes break, the synthesized yield returns false. The next yield call — the tail emit — violates the contract and the program panics with "range function continued iteration after function for loop body returned false". (Go blog, Range Over Function Types)

The rule, stated bluntly: once yield has returned false, you must not call it again. Not for cleanup. Not for one final flush. Not for a sentinel. The check is unconditional.

The fix is structural. Every yield call must be guarded by its return value:

func Pairs(items []int) iter.Seq[[2]int] {
    return func(yield func([2]int) bool) {
        for i := 0; i+1 < len(items); i += 2 {
            if !yield([2]int{items[i], items[i+1]}) {
                return
            }
            if !yield([2]int{items[i+1], -1}) {
                return
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Every yield is followed by an if-not-return. There is no "and then one more thing" after a yield that is not gated by its return value. The shape is repetitive on purpose; the repetition is the contract.

If you reach for "I want to do X after the loop body finishes regardless of break," the right tool is defer inside the iterator function. Defers run when the iterator function returns, and that includes the early-return-on-false path. (go101.org, Some Facts About Go Iterators, March 2025)

func Pairs(items []int) iter.Seq[[2]int] {
    return func(yield func([2]int) bool) {
        defer flushTail() // runs on early-return-on-false too
        for i := 0; i+1 < len(items); i += 2 {
            if !yield([2]int{items[i], items[i+1]}) {
                return
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Footgun 3: Calling yield from a goroutine

The one that catches concurrency-minded developers fastest. You have a producer that does work in parallel: a fan-out scrape, a parallel decoder, a worker pool. You wrap it as an iterator because the consumer side reads cleanly with for v := range producer(). You spawn goroutines. Each goroutine calls yield with its result.

func ParallelMap[T, R any](items []T, fn func(T) R) iter.Seq[R] {
    return func(yield func(R) bool) {
        var wg sync.WaitGroup
        for _, it := range items {
            wg.Add(1)
            go func(it T) {
                defer wg.Done()
                yield(fn(it)) // yield from a goroutine
            }(it)
        }
        wg.Wait()
    }
}
Enter fullscreen mode Exit fullscreen mode

What the compiler says: nothing. There is no goroutine-affinity annotation on yield. It is a func(R) bool like any other.

What the runtime does: under any real concurrency, panic intermittently depending on which goroutine wins. The Go documentation states it directly: parallel calls to yield are not synchronized, and overlapping yields are a contract violation. (go101.org, Some Facts About Go Iterators, March 2025)

The deeper reason: the loop body the runtime synthesizes around for v := range seq relies on invariants. At most one yield call is active at a time, and once one returns false, the range is done. Two goroutines yielding break both. The only safe behavior is panic.

The fix is to fan in before you yield. Run parallel work in goroutines, push results to a channel, and call yield serially from the iterator's own frame:

func ParallelMap[T, R any](items []T, fn func(T) R) iter.Seq[R] {
    return func(yield func(R) bool) {
        out := make(chan R)
        var wg sync.WaitGroup
        for _, it := range items {
            wg.Add(1)
            go func(it T) {
                defer wg.Done()
                out <- fn(it)
            }(it)
        }
        go func() { wg.Wait(); close(out) }()

        for r := range out {
            if !yield(r) {
                // drain to let goroutines finish
                go func() { for range out {} }()
                return
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Yield is called from one goroutine, the one that owns the iterator function frame. Parallelism happens behind the channel. The contract holds.

If your producer is genuinely parallel and you want consumers to see results as they complete, that is a chan R exposed directly, not an iterator. Iterators are sequential by construction. (Go blog, Range Over Function Types)

Footgun 4: error as the second iter.Seq2 return

Not a panic. A category error that the type system actively encourages, and once it is in your codebase you cannot easily get out.

iter.Seq2[K, V] exists for things that naturally pair: index and value, key and value, line number and line. maps.All uses it for real keys. When you have a streaming reader that can fail mid-stream, the temptation is overwhelming:

func ReadLines(r io.Reader) iter.Seq2[string, error] {
    return func(yield func(string, error) bool) {
        sc := bufio.NewScanner(r)
        for sc.Scan() {
            if !yield(sc.Text(), nil) {
                return
            }
        }
        if err := sc.Err(); err != nil {
            yield("", err)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

This compiles. It also runs. The type iter.Seq2[string, error] is well-formed, the function builds, the for line, err := range ReadLines(r) call site builds. The fail mode is silent: the success path works, and the error path quietly relies on every consumer remembering to check err on every iteration.

for line, err := range ReadLines(r) {
    // forget this and you process garbage
    if err != nil {
        log.Fatal(err)
    }
    process(line)
}
Enter fullscreen mode Exit fullscreen mode

The compiler will not warn you when you write for line := range ReadLines(r) instead of for line, err := range .... Ranges over iter.Seq2 accept a single iteration variable and silently drop the second. You can range an iterator-of-errors and never see any of them.

The design tension is not new. A 2024 proposal — golang/go#67924, iter: SeqError type, Throw function — was opened on June 10 and declined as infeasible two days later by the proposal review group, but it surfaced the underlying problem the iterator authors keep running into: iter.Seq2[T, error] is structurally fine and ergonomically broken at the call site. (golang/go#67924)

The fix is to stop putting errors in the iterator's value slot. Two patterns work:

Pattern A — error sink on the iterator's owner. Return both the iterator and an error accessor:

type LineReader struct {
    r   io.Reader
    err error
}

func (lr *LineReader) Lines() iter.Seq[string] {
    return func(yield func(string) bool) {
        sc := bufio.NewScanner(lr.r)
        for sc.Scan() {
            if !yield(sc.Text()) {
                return
            }
        }
        lr.err = sc.Err()
    }
}

func (lr *LineReader) Err() error { return lr.err }
Enter fullscreen mode Exit fullscreen mode

The consumer ranges, then checks lr.Err() after the loop. Same shape bufio.Scanner uses. The error cannot be silently dropped because the API forces a separate call.

Pattern B — fail-fast outside the iterator signature. Skip iter.Seq entirely and return func(yield func(string) bool) error, where the iterator-as-callback returns the terminal error. It breaks the for-range contract, but it composes with iter.Pull consumers and helpers that wrap iteration with explicit error handling.

func ReadLines(r io.Reader) func(yield func(string) bool) error {
    return func(yield func(string) bool) error {
        sc := bufio.NewScanner(r)
        for sc.Scan() {
            if !yield(sc.Text()) {
                return nil
            }
        }
        return sc.Err()
    }
}

// caller
if err := ReadLines(r)(process); err != nil {
    log.Fatal(err)
}
Enter fullscreen mode Exit fullscreen mode

The settled rule, repeated by people who ship iterator libraries: if your sequence can fail, do not encode the failure as the second return of iter.Seq2. Keep the error out of the iterator signature, or use a sibling channel, or expose a Reader-style API where errors live on the receiver. (Sinclair Target, Error Handling with Iterators in Go)

What the compiler catches (and what it doesn't)

None of the four are caught by go build or go vet. The first three throw at runtime. The fourth never throws and silently produces wrong output.

The runtime tracks whether the iterator has returned and whether yield has returned false. Calling yield after either of those events panics. The state machine doing the tracking is generated around every for v := range seq site and is invisible to the user. It is also the only safety net you have. (go101.org, Some Facts About Go Iterators, March 2025)

gopls's modernize bundle does ship analyzers in the iterator neighborhood — stringsseq (rewrites ranges over strings.Split/strings.Fields into the allocation-free SplitSeq/FieldsSeq) and stditerators (replaces Len/At-style legacy APIs with range-over-func). Both are worth turning on. Neither catches the four shapes above; they target the syntactic-modernization layer, not the contract-violation layer. (gopls modernize source)

What to tell a team adopting iterators

Iterators are worth adopting. The improvements to slices, maps, bufio.Lines, and strings.SplitSeq are real, and once you have a few iterator-shaped APIs in your codebase the consumer code shrinks. None of the four footguns is a reason to skip them.

The short list:

  • Treat yield as stack-frame-local. Do not capture it in a struct. Do not pass it across goroutines. Do not call it from a goroutine that outlives the iterator function.
  • Guard every yield with its return value. If you need cleanup, use defer inside the iterator function. There is no post-yield-after-false escape hatch.
  • If your producer is parallel, fan in before you yield. The iterator runs serially. Concurrency happens upstream.
  • If your sequence can fail, do not encode the failure in iter.Seq2's second slot. Use an error accessor on the owning struct, a sibling channel, or step out of iterators entirely. A silently dropped error is worse than an awkward API.
  • Turn on gopls's stringsseq and stditerators modernize analyzers in CI. They will not catch the four shapes above, but they keep the rest of your iteration code allocation-clean.

If this was useful

iter.Seq and iter.Seq2 are part of the wider story of how Go has been evolving since 1.18: generics, iterators, and the standard-library additions that depend on both. The Complete Guide to Go Programming is the language top to bottom, including the chapters on the iterator contract, runtime invariants, and how the new APIs in slices, maps, cmp, and iter compose.

Thinking in Go — the 2-book series on Go programming and hexagonal architecture

Top comments (0)