The problem with eager lists
You are writing a parser that reads a million lines from a log file. You only care about lines containing a specific error code. In older Go, you had two choices. You could read every line into a massive slice, filter it, and then loop over the result. That wastes memory and stalls the program until the entire file is loaded. Or you could spin up a goroutine, pipe lines through a channel, and close the channel when done. That works, but channels carry allocation overhead and require careful lifecycle management. You end up writing boilerplate just to avoid loading everything into RAM.
Go 1.23 removes that tradeoff. Range-over-function iterators let you write a single function that produces values on demand. The range keyword handles the loop, the function handles the generation, and the runtime stitches them together without channels or slices. You get lazy evaluation with the familiar for val := range ... syntax.
How range-over-function actually works
The feature revolves around a single type from the standard library: iter.Seq[T]. The name stands for sequence. It is a function type with a very specific signature. It takes one parameter: a callback named yield. That callback accepts a value of type T and returns a boolean. When you call yield(value), you hand one item to the loop. The loop processes it and calls your function again. If the loop breaks early, yield returns false. Your function sees that false and stops producing values.
Think of it like a factory line manager. You do not build the entire batch upfront. You hand one finished widget to the manager. The manager either says "keep going" or "we are done, shut down the line." The yield callback is that manager. It controls the pace and signals when to stop.
The design is intentionally synchronous. No goroutines are created. No channels are allocated. The iterator runs in the same call stack as the range loop. This keeps memory usage flat and makes debugging straightforward. You step through the loop, you step into the iterator, you see exactly where each value comes from.
Minimal example
Here is the simplest possible iterator. It generates even numbers up to a limit.
package main
import (
"fmt"
"iter"
)
// EvenNumbers returns a lazy sequence of even integers up to n.
func EvenNumbers(n int) iter.Seq[int] {
// The closure captures n and will be called repeatedly by range.
return func(yield func(int) bool) {
for i := 0; i < n; i++ {
// Skip odd numbers to keep the sequence clean.
if i%2 != 0 {
continue
}
// yield hands the value to the loop. If it returns false,
// the loop broke early, so we stop generating immediately.
if !yield(i) {
return
}
}
}
}
func main() {
// range calls EvenNumbers, gets the closure, then drives it.
for num := range EvenNumbers(10) {
fmt.Println(num)
}
}
The compiler sees range EvenNumbers(10). It recognizes that EvenNumbers returns iter.Seq[int]. It calls the function once to get the closure. Then it enters a hidden loop that repeatedly invokes that closure with a built-in yield callback. Each time yield is called, the loop body runs. When the closure returns, the loop ends.
Goroutines are cheap. Iterators are cheaper.
Under the hood: the callback dance
The magic is just a callback pattern dressed up in range syntax. When you write for v := range f(), the compiler rewrites it roughly like this:
- Call
f()to get the iterator function. - Create a hidden
yieldfunction that stores the next value and signals the loop to continue. - Call the iterator function, passing
yield. - The iterator calls
yield(value)for each item. yieldreturnstrueto keep going, orfalseif the loop hitbreak.- The iterator checks the return value and exits early if needed.
- When the iterator function returns, the loop terminates.
Notice that yield is a parameter name, not a keyword. Go deliberately avoided adding new keywords. You can name it emit, send, or next if you want, but the community convention is yield. Sticking to the convention makes your code instantly recognizable to other Go developers.
The synchronous design means the iterator runs on the same goroutine as the loop. If your iterator does blocking work, the loop waits. If your iterator panics, the loop panics. There is no hidden concurrency to leak or deadlock. This matches Go's preference for explicit concurrency over implicit magic.
Realistic example: lazy filtering
Real code rarely generates numbers. It processes data. Here is a practical pattern: reading lines from a string, filtering them, and stopping early when a condition is met.
package main
import (
"fmt"
"iter"
"strings"
)
// Lines returns a lazy sequence of lines from a reader.
func Lines(r *strings.Reader) iter.Seq[string] {
return func(yield func(string) bool) {
// Read line by line until EOF or early break.
for {
line, err := r.ReadString('\n')
if err != nil {
return
}
// Strip the trailing newline for cleaner output.
line = strings.TrimSuffix(line, "\n")
if !yield(line) {
return
}
}
}
}
// Filter keeps only values where the predicate returns true.
func Filter[T any](seq iter.Seq[T], pred func(T) bool) iter.Seq[T] {
return func(yield func(T) bool) {
// Delegate to the source iterator, checking each value.
for v := range seq {
if pred(v) && yield(v) {
continue
}
if !yield(v) {
return
}
}
}
}
func main() {
data := "INFO: started\nERROR: disk full\nDEBUG: retrying\nERROR: timeout\n"
reader := strings.NewReader(data)
// Chain iterators: read lines, filter for errors, print.
for line := range Filter(Lines(reader), func(s string) bool {
return strings.HasPrefix(s, "ERROR")
}) {
fmt.Println(line)
}
}
The Lines function produces strings lazily. The Filter function wraps any sequence and applies a predicate. The range loop drives both. Only one line lives in memory at a time. If you add a break inside the loop, both iterators stop immediately. The yield return value propagates the cancellation up the chain.
Convention aside: Go developers prefer composing small, pure functions over building large state machines. The iter package follows that philosophy. Each function does one thing, returns a sequence, and lets the caller decide when to stop.
Pitfalls and compiler traps
The feature is simple, but a few patterns trip people up.
If you forget to check the yield return value, your iterator keeps running after the loop breaks. The loop stops consuming values, but your function continues calling yield. Those calls silently return false, and your code wastes CPU cycles computing values nobody will see. Always guard your loop with if !yield(v) { return }.
If you try to use range-over-function for blocking I/O, you will block the calling goroutine. The iterator runs synchronously. If you need to read from a network socket or a database while keeping the main loop responsive, use a channel instead. The compiler will not stop you, but your program will stall.
Type mismatches are common when building custom iterators. The signature must match func(func(T) bool) exactly. If you accidentally return iter.Seq[string] from a function that promises iter.Seq[int], the compiler rejects the program with cannot use ... as iter.Seq[int] value in return argument. If you forget to import the iter package, you get undefined: iter. If you pass the wrong number of arguments to yield, you get too many arguments in call to yield. These are standard type-checking errors. Read them literally. They tell you exactly where the shape of your function diverges from the expected signature.
Another subtle trap is capturing loop variables. If you write an iterator inside a for loop and reference the loop index without capturing it, you will get stale values. Go 1.22+ already fixed the classic loop variable capture bug, but the principle remains: closures capture references, not snapshots. If you need a snapshot, assign the variable to a new local before using it in the closure.
The worst iterator bug is the one that silently computes forever. Always respect the yield return value.
When to reach for iterators
Go gives you multiple ways to produce sequences. Picking the right one depends on concurrency, memory, and lifecycle requirements.
Use range-over-function iterators when you need lazy, synchronous generation of values without channel overhead. Use channels when you need concurrent producers, blocking I/O, or multiple goroutines feeding the same loop. Use standard slices when the dataset fits comfortably in memory and you need random access or sorting. Use for i := range slice when you need the index alongside the value and performance matters. Use iter.Pull[T] when you need to drive an iterator from a callback-based API that expects a pull model instead of a push model.
Context is plumbing. Run it through every long-lived call site.