Iteration6 min read

Go Range-Over-Function Iterators: Yield, Stop, and Pull

Implement Go 1.23 iter.Seq correctly, honor early loop exit, convert push iterators with iter.Pull, and manage cleanup.

  • iterators
  • language semantics
  • standard library

Since Go 1.23, a for range loop can consume a function that calls a synthesized yield callback. The callback’s boolean is the stop protocol: true requests another value, while false means the loop ended early and the iterator must return without calling yield again. Use iter.Pull only when the consumer needs to request values outside a natural range loop, and always arrange to call its stop function.

This article targets Go 1.27. Range over integers arrived in Go 1.22, but range over functions and the standard iter package require Go 1.23. Code using these iterator forms must declare or otherwise build with a sufficiently new Go version.

Only three function shapes can be ranged over

The Go specification’s range clause accepts iterator functions with zero, one, or two yielded values:

func(yield func() bool)
func(yield func(V) bool)
func(yield func(K, V) bool)

The yield callback cannot be variadic. The standard iter package names the one- and two-value forms:

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

Seq2 commonly represents key-value or index-value pairs. There is no Seq0 alias, but the language accepts the zero-value form directly.

Every iterator must honor false

This complete Go 1.23+ program stops producing values when its consumer breaks:

package main

import (
	"fmt"
	"iter"
)

func EvenBelow(limit int) iter.Seq[int] {
	return func(yield func(int) bool) {
		defer fmt.Println("iterator cleanup")
		for n := 0; n < limit; n += 2 {
			if !yield(n) {
				return
			}
		}
	}
}

func main() {
	for n := range EvenBelow(10) {
		fmt.Println(n)
		if n == 4 {
			break
		}
	}
}

It prints:

0
2
4
iterator cleanup

When break terminates the loop body, the synthesized yield returns false. The iterator returns, so its deferred cleanup runs before control proceeds beyond the range loop. A return, panic, or other early termination of the loop body participates in the same protocol.

Ignoring the boolean is not a harmless extra callback. The specification says yield must not be called again after it returns false, and the iter package documentation says a later call panics. Write the check at every yield site:

if !yield(value) {
	return
}

The iterator runs as part of the loop

A range-over-function iterator is a push iterator: it calls yield, which transfers each value into the loop body. From the API user’s perspective, the loop looks like an ordinary range. The implementation does not need to create a goroutine or channel merely to sequence values.

This makes deferred cleanup in the iterator a natural way to release a lock, close a cursor, or restore state. Still, document resource ownership. An iterator that opens a resource before returning the Seq value may leak if the caller never starts ranging. Prefer opening it when the sequence function is invoked, or return an explicit close operation when construction itself acquires resources.

Panics from the iterator propagate to the loop. Panics from the loop body unwind through the iterator so its defers run. Do not recover a consumer’s panic inside a general-purpose iterator unless the API explicitly defines that behavior.

Iterator values are not necessarily reusable

The function type alone says nothing about reuse. An iterator over an immutable slice can naturally start again each time the Seq function is invoked. An iterator that closes over advancing state may be single-use.

Current standard-library APIs document the distinction. For example, Go 1.24’s strings.Lines, strings.SplitSeq, and strings.SplitAfterSeq return single-use iterators. If an API returns stateful iteration, say so. Consumers should not assume that ranging over the same Seq value twice restarts it unless the producer’s contract promises that.

Mutation rules also belong in the contract. Iterating a collection while modifying it may invalidate indices, hold a lock for the loop’s duration, or deliberately observe changes. iter.Seq standardizes control flow, not collection consistency.

Use iter.Pull for consumer-driven coordination

A range loop is best when one sequence drives the loop. Sometimes a consumer needs to advance two sequences together, peek one item at a time, or integrate iteration into another state machine. iter.Pull converts a push Seq into two functions:

next, stop := iter.Pull(seq)
defer stop()

for {
	value, ok := next()
	if !ok {
		break
	}
	consume(value)
}

next returns the next value and validity flag. After exhaustion or stop, further calls return the zero value and false. stop is safe to call more than once.

The important failure mode is abandoning a pull iterator before next reports false. The original push iterator is suspended around yield and needs the stop signal to finish and run cleanup. The official range-over-function article recommends deferring stop immediately, even when the normal path expects to exhaust the sequence.

Do not call next or stop simultaneously from multiple goroutines. The package contract declares concurrent calls an error. A panic or runtime.Goexit inside the sequence propagates through the corresponding next or stop call rather than turning into ordinary exhaustion.

Adapt with the standard library

Go’s collection packages use the shared protocol. In Go 1.23 and later:

for index, value := range slices.All(values) {
	fmt.Println(index, value)
}

for value := range slices.Values(values) {
	fmt.Println(value)
}

copy := slices.Collect(slices.Values(values))

The slices iterator APIs also include Backward, Chunk, sorting collectors, and append adapters. maps.Keys, maps.Values, maps.All, and maps.Collect use compatible sequences. These adapters compose without requiring every intermediate step to allocate a slice, although collectors intentionally materialize one.

Composition still needs stop propagation. An adapter that receives a sequence and yields transformed values must return immediately when its own yield returns false. Otherwise an early exit at the outermost loop becomes an illegal extra yield deeper in the pipeline.

Prefer the smallest lifecycle

Use a range-over-function iterator when callers should consume a sequence naturally and early exit must trigger producer cleanup. Return a slice when the result is small, reusable, randomly accessed, or must outlive the producer. Use a channel when values genuinely cross goroutine boundaries or need asynchronous coordination; an iterator alone provides no concurrency.

The protocol is compact but strict: yield at most two values, stop on false, keep resource acquisition inside a managed lifetime, document whether the sequence is reusable, and defer the stop function after iter.Pull. Following those rules makes a custom container behave like the standard library’s iterators instead of leaking producer state behind convenient syntax.