Iteration in Go: What the range-over-func proposal adds
A proposal currently under review would let for-range loops work over integers and over specially-shaped function types, opening the door to idiomatic iteration over custom containers and generators. The change is substantial enough that it may land in a future Go release; in the meantime, the full implementation is available for experimentation via gotip.
Today’s limited for-range
Go’s for-range loop is a workhorse for iterating over built-in types: arrays, slices, strings, maps, and channels. Custom data structures have no such native support, which has led to a proliferation of ad-hoc iteration APIs across the standard library and open-source code.
Ranging over integers
The first half of the proposal is straightforward: allow range over an integer expression, making
for i := range 5 {
fmt.Println(i)
}
equivalent to the classic for i := 0; i < 5; i++ loop. The integer doesn’t need to be a constant, and the loop variable is optional when you only need to repeat a body n times. The proposal notes that roughly half of all three-clause for loops in real code could be rewritten this way, including the main loops of many Go benchmarks. This is a clean syntactic shortcut, but the more interesting change is the second half.
The problem: custom containers need iteration
Since generics arrived in Go 1.18, programmers can build type-safe generic containers — but they still can’t iterate over them with for-range. Each container ends up with its own bespoke Next() method or callback-based traversal, and no single pattern dominates. The proposal aims to fix that by letting a function describe how to iterate, and letting the for-range loop drive it.
Consider a generic association list built on a slice of key-value pairs:
type AssocList[K comparable, V any] struct {
pairs []Pair[K, V]
}
Without the proposal, iterating over its elements means exposing the underlying slice or inventing a separate API. With the proposal, you add a method whose signature the compiler understands:
func (al *AssocList[K, V]) All(yield func(K, V) bool) bool {
for _, pair := range al.pairs {
if !yield(pair.key, pair.value) {
return false
}
}
return true
}
Once All exists, callers can write the natural loop:
for k, v := range al.All() {
fmt.Println(k, v)
}
How the transformation works
For a function to be eligible as a range target, it must have the shape
func(yield func(...) bool) bool
The yield function accepts zero, one, or two arguments of arbitrary type and returns bool. The compiler translates the range loop into a call to the iterator function, passing a function literal that contains the original loop body. For instance,
for x, y := range f { ... }
becomes roughly equivalent to
f(func(x T1, y T2) bool {
...
return true
})
The bool returned by yield tells the iterator whether iteration should continue. A break statement inside the loop body becomes return false, while continue becomes return true. More complex control flow — goto, early return, defer, and panic — requires more elaborate rewrites, described in detail in the implementation’s source comment.
Why early stopping matters
An iterator must honor a stop request promptly. If a for-range body hits a break, the compiler turns that into a false return from the function it passed as yield. The iterator function checks the return value and, on false, exits early itself. This becomes critical for iterators that are expensive, have side effects, or are infinite.
As an example, an iterator over Fibonacci numbers has no natural end:
func genFib(yield func(int) bool) bool {
a, b := 1, 1
for {
if !yield(a) {
return false
}
a, b = b, a+b
}
}
The enclosing for loop runs forever unless yield returns false, which happens when a consumer uses break after seeing a value above some threshold.
Composing iterators
The iterator function’s own bool return value becomes essential for more complex traversal patterns, such as an in-order walk over a binary tree:
func (t *Tree[V]) Inorder(yield func(V) bool) bool {
if t == nil {
return true
}
return t.left.Inorder(yield) && yield(t.value) && t.right.Inorder(yield)
}
Here the recursive calls chain return values. If yield returns false (because the consumer broke out of the loop), the walk short-circuits and unwinds the entire stack, visiting no further nodes. The iterator’s return value isn’t checked by the driving for-range, but it lets composed iterators communicate a stop condition among themselves.
Other useful patterns
The proposal also applies naturally to wrapping existing iteration APIs. bufio.Scanner, for instance, traditionally requires a manual loop:
scanner := bufio.NewScanner(reader)
for scanner.Scan() {
line := scanner.Text()
}
A straightforward wrapper gives the same functionality in range syntax:
func Lines(r io.Reader) func(yield func(string) bool) bool
Another example from the proposal discussion uses closures to build a reversed slice iterator:
func Backward[E any](s []E) func(func(int, E) bool) bool {
return func(yield func(int, E) bool) bool {
for i := len(s) - 1; i >= 0; i-- {
if !yield(i, s[i]) {
return false
}
}
return true
}
}
The result is a simple way to reverse-iterate a slice in a for-range loop. As with higher-order functions in general, the ability to create and return iterators as closures makes for concise, flexible code.
Trying the proposal
You can test all of this today by installing a development Go binary via gotip and building the specific patch set that implements the proposal. Once the toolchain is ready, commands like gotip run and gotip build work as expected. The proposal’s acceptance is not yet certain, but the design is coherent, and the examples above show how it would simplify a wide variety of iteration logic.
Understanding push and pull iterators
The proposal rounds out Go's language ergonomics with what appears to be minimal added complexity. A recurring concept in the associated documents is the distinction between "push" and "pull" iterators, which refers to how control flows between the iterator and the code consuming its values.
Push iterators drive the process themselves: they take a yield function and generate values by invoking it. The return value of yield signals whether the iterator should continue or stop. All the examples in the accompanying proposal and this post are push iterators.
Pull iterators are different. A pull iterator is a function you invoke repeatedly, with a signature along the lines of:
func() (value T, cont bool)
Here value is the generated value, and cont indicates whether the iterator is ready to produce more values or is done.
Because a pull iterator is externally driven, it needs to retain state between calls. Push and pull iterators each suit different patterns; the proposal in question only introduces push iterators, but the Go team is weighing options for pull iterator support. One path being considered is described in Russ Cox's post on coroutines.



