Skip to content
Codeloom
Go

Go Iterator Patterns With Range-Over-Func

Explore Go's range-over-func iterators introduced in Go 1.23, including push and pull patterns for custom sequences and lazy evaluation.

·6 min read · By Codeloom
Intermediate 9 min read

What you'll learn

  • How range-over-func iterators work in Go 1.23+
  • Writing push iterators with the iter package
  • Converting between push and pull iterator styles
  • Practical iterator patterns for filtering, mapping, and chaining

Prerequisites

None — this post is self-contained.

Go 1.23 stabilized range-over-func, letting you use for range with custom iterator functions. Before this, iterating over custom sequences required channels (with goroutine overhead), callback patterns, or manually implementing a Next/Value interface. Range-over-func gives custom sequences the same clean syntax as slices and maps.

The Iterator Function Signatures

An iterator is a function that takes a yield callback. There are two standard signatures in the iter package:

// Yields values of type V
type Seq[V any] func(yield func(V) bool)

// Yields key-value pairs
type Seq2[K, V any] func(yield func(K, V) bool)

The iterator calls yield for each element. If yield returns false, the consumer has stopped early (a break in the range loop), and the iterator should return.

A Simple Iterator

Generate a sequence of numbers:

package main

import (
    "fmt"
    "iter"
)

func Range(start, end int) iter.Seq[int] {
    return func(yield func(int) bool) {
        for i := start; i < end; i++ {
            if !yield(i) {
                return
            }
        }
    }
}

func main() {
    for v := range Range(1, 6) {
        fmt.Println(v)
    }
    // Output: 1 2 3 4 5
}

The for v := range Range(1, 6) syntax works because Range returns an iter.Seq[int]. The range loop calls the returned function, passing its own yield callback that feeds values into v.

Iterating With Index-Value Pairs

Use iter.Seq2 when you need both an index and a value:

func Enumerate[V any](s []V) iter.Seq2[int, V] {
    return func(yield func(int, V) bool) {
        for i, v := range s {
            if !yield(i, v) {
                return
            }
        }
    }
}

func main() {
    names := []string{"Alice", "Bob", "Charlie"}
    for i, name := range Enumerate(names) {
        fmt.Printf("%d: %s\n", i, name)
    }
}

Filtering

Create an iterator that only yields elements matching a predicate:

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

func main() {
    evens := Filter(Range(1, 20), func(n int) bool {
        return n%2 == 0
    })
    for v := range evens {
        fmt.Println(v)
    }
    // Output: 2 4 6 8 10 12 14 16 18
}

Notice that Filter consumes one iterator and produces another. This is lazy: no work happens until the consumer starts ranging.

Mapping

Transform each element:

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

func main() {
    squares := Map(Range(1, 6), func(n int) int {
        return n * n
    })
    for v := range squares {
        fmt.Println(v)
    }
    // Output: 1 4 9 16 25
}

Chaining Iterators

Because iterators compose, you can build pipelines:

func main() {
    // Numbers 1-100, keep evens, square them, take first 5
    result := Take(
        Map(
            Filter(Range(1, 101), func(n int) bool {
                return n%2 == 0
            }),
            func(n int) int { return n * n },
        ),
        5,
    )

    for v := range result {
        fmt.Println(v)
    }
    // Output: 4 16 36 64 100
}

func Take[V any](seq iter.Seq[V], n int) iter.Seq[V] {
    return func(yield func(V) bool) {
        count := 0
        for v := range seq {
            if count >= n {
                return
            }
            if !yield(v) {
                return
            }
            count++
        }
    }
}

The entire pipeline is lazy. Only 5 elements are computed, not 100.

Iterating Over Custom Data Structures

Iterators let custom types participate in for range. Here is a binary tree:

type Tree[V any] struct {
    Value V
    Left  *Tree[V]
    Right *Tree[V]
}

func (t *Tree[V]) InOrder() iter.Seq[V] {
    return func(yield func(V) bool) {
        t.inOrder(yield)
    }
}

func (t *Tree[V]) inOrder(yield func(V) bool) bool {
    if t == nil {
        return true
    }
    if !t.Left.inOrder(yield) {
        return false
    }
    if !yield(t.Value) {
        return false
    }
    return t.Right.inOrder(yield)
}

Usage is clean:

root := &Tree[int]{
    Value: 4,
    Left:  &Tree[int]{Value: 2, Left: &Tree[int]{Value: 1}, Right: &Tree[int]{Value: 3}},
    Right: &Tree[int]{Value: 6, Left: &Tree[int]{Value: 5}, Right: &Tree[int]{Value: 7}},
}

for v := range root.InOrder() {
    fmt.Println(v)
}
// Output: 1 2 3 4 5 6 7

Pull Iterators

The iter package provides Pull and Pull2 to convert a push iterator (the standard kind) into a pull iterator that you call manually:

func main() {
    next, stop := iter.Pull(Range(1, 10))
    defer stop()

    v1, ok := next()
    fmt.Println(v1, ok) // 1 true

    v2, ok := next()
    fmt.Println(v2, ok) // 2 true

    stop() // Release resources early

    v3, ok := next()
    fmt.Println(v3, ok) // 0 false (iterator stopped)
}

Pull iterators are useful when you need to manually control iteration, for example when merging two sorted sequences or implementing a coroutine-like pattern:

func Merge[V any](a, b iter.Seq[V], less func(V, V) bool) iter.Seq[V] {
    return func(yield func(V) bool) {
        nextA, stopA := iter.Pull(a)
        defer stopA()
        nextB, stopB := iter.Pull(b)
        defer stopB()

        va, okA := nextA()
        vb, okB := nextB()

        for okA && okB {
            if less(va, vb) {
                if !yield(va) { return }
                va, okA = nextA()
            } else {
                if !yield(vb) { return }
                vb, okB = nextB()
            }
        }
        for okA {
            if !yield(va) { return }
            va, okA = nextA()
        }
        for okB {
            if !yield(vb) { return }
            vb, okB = nextB()
        }
    }
}

Collecting Into a Slice

Convert an iterator back into a concrete slice with the slices package:

import "slices"

numbers := slices.Collect(Range(1, 6))
// []int{1, 2, 3, 4, 5}

filtered := slices.Collect(Filter(Range(1, 20), func(n int) bool {
    return n%3 == 0
}))
// []int{3, 6, 9, 12, 15, 18}

When to Use Iterators

Use iterators when you have a custom data structure that consumers should be able to range over, when you want lazy evaluation to avoid materializing large sequences, or when you need composable transformation pipelines.

For simple slices and maps, the built-in range is sufficient. Iterators add value when the sequence is computed, paginated, streamed from I/O, or needs to be transformed before consumption.

Wrap Up

Go’s range-over-func iterators bring lazy, composable sequences to the language with a clean syntax. Write iterator functions that call yield for each element, compose them with Filter, Map, and Take, and convert between push and pull styles when needed. They give custom data structures the same ergonomic for range loop that slices and maps enjoy.