Go 1.23 stabilised range over function iterators, a feature that had been in the rangefunc experiment since 1.22. It’s the most significant addition to the range statement since channels were added.

The reaction has been mixed: people who needed it find it elegant; people who didn’t need it find it confusing. Both reactions are reasonable. Here’s what it actually does and where it belongs.

The Problem It Solves

Before 1.23, if you wanted to iterate over a custom data structure, you had two options:

Option 1: Return a slice. Collect all elements into a []T and return it. Simple. Works well. Has two costs: allocates memory proportional to the full collection, and requires collecting everything before iteration starts.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// Must build the entire tree in memory before iterating
func (t *Tree[K, V]) AllNodes() []*Node[K, V] {
    var nodes []*Node[K, V]
    t.walk(t.root, func(n *Node[K, V]) {
        nodes = append(nodes, n)
    })
    return nodes
}

for _, n := range tree.AllNodes() {  // iterates the slice
    process(n)
}

Option 2: Accept a callback. Pass a function to the data structure and let it call back for each element. Zero allocation, streaming. Awkward to use: can’t break out of the loop without returning a special value from the callback.

1
2
3
4
5
6
7
tree.Walk(func(n *Node[K, V]) bool {
    if done(n) {
        return false  // stop iteration
    }
    process(n)
    return true  // continue
})

Range over functions is option 2, but with the caller-side range syntax instead of the callback syntax.

The Iterator Function Signature

An iterator function for range has one of these signatures:

1
2
3
4
5
// Value-only iterator (range over values)
func(yield func(V) bool)

// Key-value iterator (range over key-value pairs)
func(yield func(K, V) bool)

The yield function is called for each element. If yield returns false, iteration stops (the caller did break). The iterator function must stop calling yield when it returns false.

A tree iterator:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
func (t *Tree[K, V]) All() iter.Seq2[K, V] {
    return func(yield func(K, V) bool) {
        var walk func(*Node[K, V]) bool
        walk = func(n *Node[K, V]) bool {
            if n == nil {
                return true
            }
            // In-order traversal: left, current, right
            if !walk(n.left) {
                return false  // yield returned false — stop
            }
            if !yield(n.key, n.value) {
                return false
            }
            return walk(n.right)
        }
        walk(t.root)
    }
}

// Usage:
for k, v := range tree.All() {
    if done(k) {
        break  // works — calls yield(false) internally
    }
    process(k, v)
}

The iter.Seq[V] and iter.Seq2[K, V] types from the iter package are just type aliases for the iterator function signatures. They’re documentation, not a new mechanism.

The Standard Library Additions

Go 1.23 added iterator functions to several standard library types:

1
2
3
4
5
6
7
8
9
// slices package
for i, v := range slices.All(mySlice) {  // equivalent to normal slice range
for v := range slices.Values(mySlice) {
for i, v := range slices.Backward(mySlice) {  // reverse iteration

// maps package
for k, v := range maps.All(myMap) {  // equivalent to map range
for k := range maps.Keys(myMap) {
for v := range maps.Values(myMap) {

These are convenient but not compelling — you could already range over slices and maps. The value is in the composability with iterator combinators:

1
2
3
4
// Filter then take first 5 that match
for k, v := range slices.All(items) {
    // works with early return/break naturally
}

The iter Package Combinators

The iter package provides Pull for consuming push-based iterators in a pull-based way, and external packages are emerging with combinators (map, filter, take, zip). The pattern:

1
2
3
4
5
6
7
import "iter"
import "golang.org/x/exp/xiter"  // experimental combinators

// Filter and transform using iterator composition:
for v := range xiter.Filter(xiter.Map(tree.All(), transform), predicate) {
    process(v)
}

This is where the feature gets interesting for data pipeline-style code. Iterator composition without intermediate allocations, with natural break semantics.

Where It Belongs and Where It Doesn’t

Use range over functions for:

  • Custom data structures (trees, graphs, ordered sets) where traversal needs to be lazy
  • Wrapping external iteration sources (database cursors, file lines, API pagination) where you don’t want to buffer everything in memory
  • Stateful generators that produce values on demand
  • Library code where composability matters

Don’t use range over functions for:

  • Wrapping a []T that you already have in memory — just range over the slice
  • Simple transformations on collections — a loop or a helper function is clearer
  • Making code look “more functional” — iterator chains with Map/Filter/Reduce are often less readable than explicit loops in Go’s style

The rule of thumb: if you’d previously have written a Walk(func(v V) bool) method on your type, replace it with an iterator function and let callers use range. If you’d previously have returned a []T, the slice is probably still right.

The Pitfall: Forgetting to Honour false

The contract of an iterator function: if yield returns false, you must stop calling yield. Violating this causes a panic.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// WRONG: ignores yield return value
func BadIterator(yield func(int) bool) {
    for i := 0; i < 10; i++ {
        yield(i)  // must check return value!
    }
}

// RIGHT:
func GoodIterator(yield func(int) bool) {
    for i := 0; i < 10; i++ {
        if !yield(i) {
            return  // stop when caller does break/return
        }
    }
}

This is a new failure mode that didn’t exist before. Linters will catch it eventually; for now, code review is the check.


Range over functions is a clean solution to a real problem: lazy iteration over custom data structures with natural break semantics. It fills the gap between “return a slice” (eager, allocates) and “accept a callback” (lazy, awkward). Whether it belongs in your code depends on whether you have that problem. Most service-layer code doesn’t — it ranges over slices and maps. Infrastructure and library code often does.