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.
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.
Related articles
- Go Go Concurrency: Fan-Out, Pipeline, and Worker Pool
Master advanced Go concurrency patterns including fan-out/fan-in, pipelines, and worker pools with practical examples and production-ready code.
- Go Go Database Patterns with sqlx and pgx
Master Go database patterns using sqlx and pgx for PostgreSQL including connection pooling, transactions, batch operations, and repository design.
- Go Go Generics: Practical Patterns and Constraints
Learn practical Go generics patterns including type constraints, generic data structures, utility functions, and when to use generics vs interfaces.
- Go Building gRPC Services in Go: Complete Guide
Build production-ready gRPC services in Go with protobuf definitions, server and client implementation, streaming, interceptors, and error handling.