A new kind of loop
Go 1.23 introduced a significant language feature: ranging over functions, commonly called "iterators." The change, based on proposal #61405, expands the for-range statement beyond its traditional targets — arrays, slices, strings, maps, and channels — to include integers and specially-shaped functions.
For years, the for ... := range loop has been a staple of Go programming. It works cleanly for built-in containers:
for i, elem := range mySlice {
// use index i or element elem somehow
}
But custom data structures — particularly those built after Go 1.18 brought generics — had no idiomatic way to participate in for-range iteration. Programmers had to design ad-hoc iteration APIs for every container, leading to inconsistent patterns across the ecosystem. The new feature standardizes this.
Range over integers
The simplest addition is ranging over an integer. A basic example:
for i := range 5 {
fmt.Println(i)
}
This is exactly equivalent to the classic three-clause loop:
for i := 0; i < 5; i++ {
fmt.Println(i)
}
It prints 0 through 4, each on its own line. The ranged value need not be a constant, and you can omit the iteration variable if you just want to repeat an action n times:
for range n {
// do something
}
This pattern covers a large portion of real-world three-clause for loops — approximately half of those Go team member Russ Cox observed in existing code. Even benchmarks benefit:
for range b.N {
// run the benchmarked code
}
Range over functions: the motivation
Generics in Go 1.18 enabled efficient, type-safe custom containers. But without language support for iteration, using these containers remained clunky. Consider a simple generic association list built on a slice:
type AssocList[K comparable, V any] struct {
lst []pair[K, V]
}
type pair[K comparable, V any] struct {
key K
value V
}
func (al *AssocList[K, V]) Add(key K, value V) {
al.lst = append(al.lst, pair[K, V]{key, value})
}
You can create and populate one easily:
al := &AssocList[int, string]{}
al.Add(10, "ten")
al.Add(20, "twenty")
al.Add(5, "five")
The natural question: how do you iterate over its entries? One option is ranging over the underlying slice directly, but that leaks implementation details. Another is inventing a custom Next()-style API—what Go programmers have done for years, with many divergent approaches.
Go 1.23 settles this with a single idiomatic mechanism. Adding an All method to the container enables standard iteration:
func (al *AssocList[K, V]) All() iter.Seq2[K, V] {
return func(yield func(K, V) bool) {
for _, p := range al.lst {
if !yield(p.key, p.value) {
return
}
}
}
}
The Seq2 type here is a helper defined in the new standard library iter package:
type Seq[V any] func(yield func(V) bool) type Seq2[K, V any] func(yield func(K, V) bool)
Usage is straightforward:
func main() {
al := &AssocList[int, string]{}
al.Add(10, "ten")
al.Add(20, "twenty")
al.Add(5, "five")
for k, v := range al.All() {
fmt.Printf("key=%v, value=%v\n", k, v)
}
}
// Prints:
//
// key=10, value=ten
// key=20, value=twenty
// key=5, value=five
How the mechanics work
For a value to be eligible for for-range iteration, it must be a function with one of these signatures:
func(yield func() bool) func(yield func(V) bool) func(yield func(K, V) bool)
The function parameter, conventionally named yield, can take 0, 1, or 2 parameters and returns a bool. The number of yield parameters maps to the maximum number of values the loop can receive:
for x, y := range ... // two parameters for x := range ... // one parameter for range ... // no parameters
The compiler transforms the loop automatically. Per the proposal:
For a functionf, the iteration proceeds by callingfwith a synthesizedyieldfunction that invokes the body of the loop. The values produced correspond to the arguments in successive calls toyield. As with range over other types, it is permitted to declare fewer iteration variables than there are iteration values. The return value from theyieldfunction reports whetherfshould continue iterating. For example, if the loop body executes abreakstatement, the corresponding call toyieldreturnsfalse.
Concretely, the compiler rewrites a call like for k, v := range al.All() into something like:
In the simplest case, the iterator function loops over its data and hands each element to yield, which stands in for the original loop body. Control flow adds complexity: break becomes a return false from yield to signal early termination; continue becomes an early return true. Handling goto, early returns, panics, and defer requires more elaborate rewriting, detailed in the compiler implementation.
Handling early stops
Respecting yield's return value is critical. Revisit the AssocList.All method:
func (al *AssocList[K, V]) All() iter.Seq2[K, V] {
return func(yield func(K, V) bool) {
for _, p := range al.lst {
if !yield(p.key, p.value) {
return
}
}
}
}
The iterator must check if yield returned false and exit early. Why? Consider a loop that breaks partway through:
for k, v := range al.All() {
if strings.HasPrefix(v, "fi") {
fmt.Println("found bad value, aborting!")
break
}
fmt.Printf("key=%v, value=%v\n", k, v)
}
Once a "bad value" is found, the break converts into return false in the synthesized yield. If the iterator ignores this and keeps generating values, it wastes computation, applies side effects, or runs forever — iteration might read from I/O devices or be non-finite.
An infinite iterator: Fibonacci numbers
Iterators need not be bounded. This function generates Fibonacci numbers endlessly:
func genFib() iter.Seq[int] {
return func(yield func(int) bool) {
a, b := 1, 1
for {
if !yield(a) {
return
}
a, b = b, a+b
}
}
}
It returns iter.Seq because each iteration yields a single value. Usage:
func main() {
for p := range genFib() {
fmt.Println(p)
if p > 1000 {
break
}
}
}
This prints Fibonacci numbers until the first value exceeding 1000. The inner for loop in genFib has no end condition; it exits only when yield returns false, which happens when the if p > 1000 condition triggers the loop's break.
Recursive iteration
Not all iteration is linear. Binary tree traversal requires recursion:
type Tree[E any] struct {
val E
left, right *Tree[E]
}
func (t *Tree[E]) Inorder() iter.Seq[E] {
return func(yield func(E) bool) {
t.push(yield)
}
}
func (t *Tree[E]) push(yield func(E) bool) bool {
if t == nil {
return true
}
return t.left.push(yield) && yield(t.val) && t.right.push(yield)
}
A recursive helper push walks the tree in-order. It returns a boolean so it can propagate the stop signal up the recursion stack, stopping as soon as yield returns false. The result works in a standard loop:
// Create a sample tree:
//
// 10
// / \
// 20 40
// / \
// 30 39
tt := &Tree[int]{
10,
&Tree[int]{
20,
&Tree[int]{30, nil, nil},
&Tree[int]{39, nil, nil}},
&Tree[int]{40, nil, nil},
}
for v := range tt.Inorder() {
fmt.Println(v)
}
// Prints:
// 30
// 20
// 39
// 10
// 40
More examples
The bufio.Scanner type has long exemplified ad-hoc iteration:
scanner := bufio.NewScanner(os.Stdin)
for scanner.Scan() {
fmt.Println(scanner.Text())
}
if err := scanner.Err(); err != nil {
fmt.Fprintln(os.Stderr, "reading standard input:", err)
}
This pattern works but isn't standardized. Wrapping the scanner, we can expose an idiomatic iterator:
type myScanner struct {
s *bufio.Scanner
}
func newScanner(r io.Reader) *myScanner {
s := bufio.NewScanner(r)
return &myScanner{
s: s,
}
}
func (ms *myScanner) All() iter.Seq[string] {
return func(yield func(string) bool) {
for ms.s.Scan() {
if !yield(ms.s.Text()) {
return
}
}
}
}
func (ms *myScanner) Err() error {
return ms.s.Err()
}
And use it directly in a for-range loop:
scanner := newScanner(os.Stdin)
for line := range scanner.All() {
fmt.Println("got line:", line)
}
if err := scanner.Err(); err != nil {
log.Fatalf("reading stdin: %v", err)
}
Iterator-returning functions don't need to be methods. A free-standing function can also return an iterator, perhaps using parameters to define iteration:
func Backward[E any](x []E) iter.Seq2[int, E] {
return func(yield func(int, E) bool) {
i := len(x) - 1
for i >= 0 && yield(i, x[i]) {
i--
}
}
}
Usage:
func main() {
s := []int{5, 6, 7, 8, 11, 22}
for _, e := range Backward(s) {
fmt.Println(e)
}
}
// Prints:
// 22
// 11
// 8
// 7
// 6
// 5
You don't need to write this yourself — the standard slices package already provides a Backward function along with other iterator helpers (search its documentation for iter.Seq).
Push versus pull iterators
Documentation for this feature often mentions "push" and "pull" iterators. The distinction is simple. Push iterators drive the process: they take a yield function and feed values into it, checking the return value to decide whether to continue. Everything in this article is a push iterator.
Pull iterators work in reverse. They are functions you call repeatedly, each call returning the next value and a continuation flag:
func() (value T, cont bool)
The caller drives a pull iterator, which must retain state between calls. Each style suits different patterns; the Go blog post shows a pull-based example, and the standard library provides iter.Pull to convert a push iterator into a pull one.
The feature improves Go's ergonomics with modest added complexity, and custom containers can finally iterate as naturally as slices and maps.



