What Are Range-Over-Function Iterators in Go (Go 1.23+)

Range-over-function iterators in Go 1.23+ enable custom iteration by passing a yield-based function directly to a range loop.

The problem with eager loading

You are processing a two gigabyte log file. You only need the first hundred lines that contain a specific error code. In Python, you write a generator with yield and let the runtime hand you lines one at a time. In Go 1.22 and earlier, you either load the entire file into a slice, write a struct with a Next() method, or spin up a channel and a background goroutine just to stream data. Each approach carries overhead. The slice wastes memory. The struct adds boilerplate. The channel introduces concurrency where you only need sequential lazy evaluation.

Go 1.23 introduces range-over-function iterators. You pass a function directly to range. The function produces values on demand. The loop pulls them one by one. No extra goroutines. No intermediate slices. Just a clean, lazy sequence that stops exactly when you tell it to.

How the mechanism actually works

An iterator in this context is a value producer that defers work until the consumer asks for the next item. The range keyword is the consumer. The function you pass is the producer. The bridge between them is a callback function, conventionally named yield.

Think of a factory assembly line. The conveyor belt (range) moves forward only when a worker places a part on it. The worker (your function) stands at a station with raw materials. When the belt signals readiness, the worker grabs a material, shapes it, and hands it over via a specific handoff protocol (yield). If the belt operator flips a switch to stop production, the handoff protocol returns a signal. The worker sees that signal, stops shaping, and walks away. The factory shuts down cleanly.

In Go terms, the handoff protocol is a function with the signature func(T) bool. T is the type of value you are producing. The return value is a boolean. true means keep going. false means the loop hit a break or finished naturally, so stop producing. The compiler generates the callback behind the scenes. You never write it yourself. You only call it.

The minimal pattern

Here is the simplest possible iterator: a function that counts up to a limit and yields each number.

// CountTo returns an iterator that yields integers from 1 to n.
func CountTo(n int) func(func(int) bool) {
	return func(yield func(int) bool) {
		for i := 1; i <= n; i++ {
			// yield hands the value to range and returns true to continue
			if !yield(i) {
				// false means range broke out, so we exit early
				return
			}
		}
	}
}

func main() {
	for v := range CountTo(5) {
		fmt.Println(v)
	}
}

The outer function returns a closure. That closure matches the exact signature range expects. The closure runs once when range starts. Inside the closure, a standard for loop calls yield repeatedly. Each call pauses the closure, runs the range loop body, then resumes the closure on the next iteration. The boolean return from yield is the only way the closure knows the loop stopped.

Step by step execution

When the compiler sees for v := range CountTo(5), it does not call CountTo and then iterate over a collection. It calls CountTo exactly once. CountTo returns a function. The compiler stores that function and calls it with a hidden callback.

The hidden callback captures the loop variable v. When your closure calls yield(1), the callback assigns 1 to v and returns true. The loop body runs. range then calls the callback again, which triggers your closure to continue past the yield call. Your closure increments i, calls yield(2), and the cycle repeats.

If your loop body contains break, the callback returns false on the next yield call. Your closure sees false, returns immediately, and the entire iteration ends. The closure never runs again. This is why checking the return value of yield is mandatory. Ignoring it turns a bounded sequence into an infinite loop that silently ignores break.

The standard library formalizes this pattern with the iter.Seq[T] type alias. It is literally type Seq[T any] func(func(T) bool). Using the alias makes signatures readable and lets you compose iterators with functions like iter.Filter or iter.Map from the iter package. The compiler treats the alias and the raw function signature identically.

A realistic filtering pipeline

Production code rarely counts to five. It usually transforms or filters existing data without copying it. Here is a lazy filter that skips empty strings and trims whitespace, returning only valid entries.

// ValidLines returns an iterator that yields non-empty, trimmed strings from src.
func ValidLines(src []string) func(func(string) bool) {
	return func(yield func(string) bool) {
		for _, raw := range src {
			// trim whitespace to normalize input before checking length
			clean := strings.TrimSpace(raw)
			if clean == "" {
				// skip blanks without yielding, preserving lazy behavior
				continue
			}
			// yield the cleaned value and respect the loop control signal
			if !yield(clean) {
				return
			}
		}
	}
}

func main() {
	data := []string{"  hello ", "", "world", "  ", "go"}
	for line := range ValidLines(data) {
		fmt.Println(line)
	}
}

This pattern avoids allocating a new slice for the filtered results. Memory usage stays constant regardless of input size. The range loop pulls values exactly when needed. If you break after the first match, the remaining strings are never processed. Lazy evaluation saves CPU cycles and memory simultaneously.

Where things go wrong

Iterators look simple, but a few patterns trip up developers coming from other languages.

Capturing loop variables inside the iterator closure is a classic mistake. If you write a helper that spawns a goroutine or stores a reference to a loop variable, you will get the last value of that variable, not the current one. The compiler rejects accidental captures in some contexts with loop variable captured by func literal after Go 1.22 tightened the rules. Declare a new variable inside the loop body if you need a stable reference.

Forgetting to check the yield return value is the most common runtime bug. The loop will appear to hang or ignore break statements. The compiler cannot enforce the check because yield is just a callback. You must write if !yield(v) { return } explicitly. The community treats this as standard boilerplate, similar to if err != nil { return err }. The verbosity keeps the control flow visible.

Another pitfall is mixing iterators with blocking operations. If your iterator reads from a network socket or waits on a channel without a timeout, and the consumer breaks early, the iterator may block forever. The goroutine that started the range loop exits, but the iterator closure is still running inside the same call stack. It will not leak a goroutine, but it will leak a thread of execution until the blocking call returns or the program exits. Always design iterators to be cancellable or bounded.

Passing the wrong function signature to range triggers a compile time error. The compiler expects a function that takes a callback and returns nothing. If you pass a function that returns a value, or a function that takes no arguments, you get cannot range over ... (variable of type func() int). The fix is always to wrap the logic in a closure that matches func(func(T) bool).

Convention matters here. The callback is almost always named yield. The outer function is named like a verb or a noun phrase describing the sequence: Lines, IDs, FilteredUsers. The iter.Seq[T] alias is the standard way to document the return type. Stick to these patterns and other Go developers will read your code instantly.

Choosing the right iteration style

Iteration in Go has several tools. Picking the wrong one adds complexity without buying you performance. Match the tool to the constraint.

Use a range-over-function iterator when you need lazy evaluation over a bounded sequence without allocating a full slice. Use a channel when multiple goroutines need to produce or consume values concurrently and you require backpressure or fan-out. Use a traditional struct iterator with a Next() method when you need to expose a public API that maintains complex internal state across explicit calls. Use a plain slice when the data fits comfortably in memory and you want the simplest possible loop. Use a standard for loop with an index when you need random access or need to modify the underlying collection during iteration.

Iterators are not a replacement for concurrency. They are a replacement for eager allocation. Keep the sequence lazy, respect the yield signal, and let range do the heavy lifting.

Where to go next