Skip to content
gohacks.dev
Basics

Range Over Integers and Functions in Go 1.22 and 1.23

Go 1.22 lets you range over an integer and Go 1.23 over an iterator function. See how yield works, the iter package helpers, early break and the go.mod rule.

8 min read
Summarize in AI
Flat illustration of an editor window full of code.

Two small changes to for loops landed in two consecutive Go releases, and they look unrelated until you see them side by side. Go 1.22 lets you range over an integer, which retires the three-clause counting loop in the usual case. Go 1.23 lets you range over a function, which gives every collection and every stream the same loop syntax without building a slice first. The second one is a bigger deal than it appears, and also a feature you can ignore until a library hands you an iterator. This article shows both in working code, builds an iterator by hand to make yield concrete, uses the new standard library helpers, and ends with the rule that decides whether your module can use any of it. It extends for range over slices, maps, strings and channels and is part of the Go basics path.

Tested with Go 1.25.6, macOS on Apple Silicon (arm64)

Counting with an integer

The old counting loop takes three clauses, and the new one takes none:

main.goGo
package main

import "fmt"

func main() {
	for i := range 3 {
		fmt.Print(i, " ")
	}
	fmt.Println()

	n := -2
	for range n {
		fmt.Println("never runs")
	}

	var small uint8 = 3
	for i := range small {
		fmt.Printf("%T:%d ", i, i)
	}
	fmt.Println()

	for range 2 {
		fmt.Print("tick ")
	}
	fmt.Println()
}
Text
0 1 2 
uint8:0 uint8:1 uint8:2 
tick tick

range 3 yields 0, 1, 2. A zero or negative count runs nothing, with no error, so for range len(xs)-1 on an empty slice is a quiet no-op instead of a loop that wraps around. The loop variable has the type of the operand: ranging over a uint8 gives uint8 values, as the %T shows, and ranging over an untyped constant like 3 gives an int. Leave the variable out entirely for the "do this N times" case. The old form stays valid and is still the right one when you step by more than one, count down, or change the counter inside the loop.

What an iterator is

A function that follows one shape can be ranged over. The shape is a function that takes a callback, conventionally called yield, and calls it once for each value. If yield returns false, the loop body has asked to stop, and the iterator must return:

main.goGo
package main

import (
	"fmt"
	"iter"
)

func fibonacci() iter.Seq[int] {
	return func(yield func(int) bool) {
		a, b := 0, 1
		for {
			if !yield(a) {
				return
			}
			a, b = b, a+b
		}
	}
}

func main() {
	for n := range fibonacci() {
		if n > 100 {
			break
		}
		fmt.Print(n, " ")
	}
	fmt.Println()
}
Text
0 1 1 2 3 5 8 13 21 34 55 89

The iterator never ends on its own, and the loop stops it with break. The compiler rewrites the loop body into a function and passes it as yield, so break becomes return false from that body and the iterator sees false. That is the whole mechanism. iter.Seq[V] is just the type name for func(yield func(V) bool), and iter.Seq2[K, V] is the two-value form used for maps and for index and value pairs. You can write one for a file, a database cursor, a tree traversal or a paginated API, and callers loop over it the same way as over a slice.

The advantage is laziness. Nothing here built a million Fibonacci numbers. The loop pulled the values it needed. A function that returned a slice would have to decide how many to compute first.

The standard library already uses them

You do not have to write iterators to use them. The packages added helpers that return iter.Seq and iter.Seq2 values, and these are the ones I use:

main.goGo
package main

import (
	"fmt"
	"maps"
	"slices"
	"strings"
)

func main() {
	text := "alpha\nbeta\ngamma"

	for line := range strings.Lines(text) {
		fmt.Printf("%q ", line)
	}
	fmt.Println()

	for part := range strings.SplitSeq("a,b,c", ",") {
		fmt.Print(part, " ")
	}
	fmt.Println()

	ages := map[string]int{"ada": 36, "grace": 45}
	keys := slices.Sorted(maps.Keys(ages))
	fmt.Println(keys)

	for i, v := range slices.Backward([]string{"x", "y", "z"}) {
		fmt.Print(i, v, " ")
	}
	fmt.Println()
}
Text
"alpha\n" "beta\n" "gamma" 
a b c 
[ada grace]
2z 1y 0x

strings.Lines and strings.SplitSeq arrived in Go 1.24 and yield pieces one at a time without allocating a slice of all of them, which matters when you split a large string and only need the first few pieces. maps.Keys returns an iterator over keys, and slices.Sorted collects an iterator into a sorted slice, the combination that the map iteration article uses to get a stable order. slices.Backward yields index and value pairs from the end. The same package also has slices.Collect to turn any iterator into a slice and slices.Values to go the other way.

Composing: filter and map by hand

An iterator can take another iterator and return a new one, and a few small functions are enough to build a lazy pipeline:

main.goGo
package main

import (
	"fmt"
	"iter"
	"slices"
)

func Filter[V any](seq iter.Seq[V], keep func(V) bool) iter.Seq[V] {
	return func(yield func(V) bool) {
		for v := range seq {
			if keep(v) && !yield(v) {
				return
			}
		}
	}
}

func Map[V, W any](seq iter.Seq[V], f func(V) W) iter.Seq[W] {
	return func(yield func(W) bool) {
		for v := range seq {
			if !yield(f(v)) {
				return
			}
		}
	}
}

func main() {
	nums := slices.Values([]int{1, 2, 3, 4, 5, 6})
	evens := Filter(nums, func(n int) bool { return n%2 == 0 })
	squares := Map(evens, func(n int) int { return n * n })

	fmt.Println(slices.Collect(squares))
}
Text
[4 16 36]

The pipeline computes nothing until slices.Collect pulls from it, and then each value flows through both stages one at a time. The standard library does not ship Filter and Map for iterators on purpose, partly because loops over iterators already read as plainly as the pipeline does, and I would also hold back before writing helpers like these in application code. A two-line for loop with an if is often clearer, and the generic helpers are the first thing a new reader has to decode.

Pull-style iteration

Occasionally you need to advance an iterator by hand, for example to merge two sorted streams. iter.Pull converts a push iterator into next and stop functions:

main.goGo
package main

import (
	"fmt"
	"iter"
	"slices"
)

func main() {
	next, stop := iter.Pull(slices.Values([]string{"a", "b", "c"}))
	defer stop()

	for {
		v, ok := next()
		if !ok {
			break
		}
		fmt.Print(v, " ")
	}
	fmt.Println()
}
Text
a b c

Pull runs the iterator on a coroutine so that next can resume it. Always call stop, which releases the coroutine, and the defer above is the habit. Pull iterators cost more than range loops, so prefer the loop form whenever you can.

Two rules iterators must follow

An iterator function must stop calling yield the moment it returns false. If it keeps going, the runtime detects it and panics, because the loop body that yield represents has already exited and may not be safe to run again. The message is range function continued iteration after function for loop body returned false. This is the usual bug in a hand-written iterator that ignores the return value of yield, and it appears only when a caller uses break.

main.goGo
package main

import "fmt"

func bad(yield func(int) bool) {
	for i := 0; i < 3; i++ {
		yield(i)
	}
}

func main() {
	for v := range bad {
		fmt.Println(v)
		break
	}
}
Text
0
panic: runtime error: range function continued iteration after function for loop body returned false

...

The iterator here ignores what yield returns. The first value prints, break makes the next yield return false, the iterator calls it again anyway, and the program panics. Without the break this code works, which is why the bug survives until a caller stops early. Always write if !yield(v) { return }.

The other rule is about errors. An iterator has no place to return one. Streams that can fail, such as lines read from a file, either use iter.Seq2[V, error] and yield the error as a value, or expose the error through a method on the iterator's owner after the loop, like bufio.Scanner.Err(). The Seq2[V, error] shape is the more common choice for new code.

The go line decides

Both forms need the language version. A module that declares go 1.21 gets a compile error for each, naming the version it needs:

main.goGo
package main

import "fmt"

func main() {
	for i := range 3 {
		fmt.Println(i)
	}
}
Text
# example.test/lab
./main.go:6:17: cannot range over 3 (untyped int constant): requires go1.22 or later (-lang was set to go1.21; check go.mod)

The same file under go 1.22 or later compiles. This is the mechanism covered in the loop variable change, applied to a new syntax instead of a new meaning. If you maintain a library for users on older Go versions, keep the go line low and avoid the new loops. For an application, raise the line to whatever the toolchain you deploy with supports.

When not to write an iterator

If the data is already in a slice and fits in memory, a slice and a for range loop are simpler and faster. If callers need random access or the length, an iterator hides both. If the iteration involves I/O that can fail midway, remember that you are choosing an error convention along with the loop. Iterators shine for large or infinite sequences, for wrapping a traversal such as a tree walk whose recursion would otherwise need a callback, and for APIs that want to offer filtering without allocating. The Go blog's article on range over function iterators is the design walkthrough, and the iter package documentation defines Seq, Seq2 and Pull with the rules above.

Also inGenerics
Taggedgo 1 23
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.