Go's iter.Seq and Range-over-Func: What the New Iterator Protocol Actually Costs You
Go 1.23 added a new shape of function that range understands directly. Give it something with the signature func(yield func(V) bool) and you can write for v := range seq { ... } exactly as if seq were a slice. The standard library leaned into it immediately: slices.Values, slices.All, maps.Keys and friends all return iter.Seq or iter.Seq2 values now, and slices.Collect turns one back into a slice when you're done being lazy about it.
It reads like syntax sugar, and at the call site it mostly is. Underneath, though, the compiler is doing real work to make break, return and goto behave sanely inside something that used to be a plain loop body and is now, secretly, a function literal passed to someone else's code. That work isn't free, and the protocol comes with sharp edges that only show up once you start writing your own iterators rather than just consuming slices.Values.
The shape of an iterator
An iter.Seq[V] is nothing more than a function that takes a callback and calls it once per element:
type Seq[V any] func(yield func(V) bool)
func Evens(n int) iter.Seq[int] {
return func(yield func(int) bool) {
for i := 0; i < n; i++ {
if i%2 == 0 && !yield(i) {
return
}
}
}
}
The contract is small: call yield with each value, and stop as soon as it returns false. That's the entire protocol. Composing iterators is then just writing functions that take one iter.Seq and return another:
func Filter[V any](seq iter.Seq[V], keep func(V) bool) iter.Seq[V] {
return func(yield func(V) bool) {
for v := range seq {
if keep(v) && !yield(v) {
return
}
}
}
}
This is the appealing part, and it's genuinely useful: lazy, allocation-light pipelines over anything that can produce values, without a channel or a goroutine in sight. The interesting bit is what for v := range seq actually turns into to make this work with ordinary Go control flow.
What range actually compiles to
For the simple case, the mental model is accurate: the loop body becomes the function literal you're implicitly passing as yield. Roughly:
for v := range Evens(10) {
fmt.Println(v)
if v > 4 {
break
}
}
// becomes, approximately:
Evens(10)(func(v int) bool {
fmt.Println(v)
if v > 4 {
return false
}
return true
})
break becomes return false, continue becomes an early return true. Fine. But a plain boolean can't express everything a loop body is allowed to do. What about a labelled break that targets an outer loop, a return from the function containing the range statement, or a goto to a label outside the loop entirely? None of those can be encoded as "return false from this callback", because by the time yield returns, control is back inside Evens, which may still have cleanup work queued before it hands control back to whoever called it.
The actual compiler, in cmd/compile/internal/rangefunc/rewrite.go, handles this by generating extra state: a variable that records why the body function stopped, alongside the boolean. After the call into the iterator returns, the generated code inspects that state and performs the real return, goto or labelled break at that point, one frame up from where you'd naively expect it. It isn't panic-based unwinding, contrary to what you might guess from how gnarly the problem sounds. But it is a small state machine bolted onto every range-over-func loop that uses non-trivial control flow, and it's why the early experimental version of this feature (behind GOEXPERIMENT=rangefunc in 1.22) had a longer list of restrictions than the version that actually shipped.
The protocol punishes misuse on purpose
Because yield is an ordinary function value, nothing stops you from holding onto it, calling it twice, or handing it to a goroutine. The runtime checks for exactly this and panics deliberately, because a violated iterator contract is a correctness bug that's much nastier to debug than a crash. Call yield again after it has already returned false, and you get a runtime panic to that effect ("range function continued iteration after function for loop body returned false" is the message you'll see in practice). Call it after the enclosing range loop has already exited, and it panics for the same reason.
The subtler version of this bug is handing yield to concurrent code:
func Broken() iter.Seq[int] {
return func(yield func(int) bool) {
var wg sync.WaitGroup
for i := 0; i < 3; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
yield(i) // wrong: yield must be called sequentially, from the iterator's own goroutine
}(i)
}
wg.Wait()
}
}
This looks like a reasonable way to parallelise producing values, and it will misbehave: yield is only safe to call synchronously, once at a time, from within the iterator function itself. There's no compile-time signal that you've broken this; you find out at runtime, possibly under a race detector, possibly just from a confusing panic. If you need concurrency internally, do it before calling yield, not around it.
Defer behaves exactly like it always did
Given that the loop body is secretly a function literal now, it's a reasonable guess that defer inside a range-over-func body might fire at the end of each iteration rather than at the end of the enclosing function. It doesn't. A defer written inside a for ... range seq body still runs when the function containing the loop returns, exactly as it would in a classic for i := range slice loop:
func Process(paths iter.Seq[string]) {
for p := range paths {
f, err := os.Open(p)
if err != nil {
continue
}
defer f.Close() // still piles up until Process returns, same as any other loop
// use f
}
}
The usual advice, don't defer inside a loop if you're opening a lot of files, still applies unchanged. That's arguably the least surprising thing about the whole feature, but it's worth stating plainly because the implementation details make it easy to assume otherwise.
It is not a zero-cost abstraction
Ranging directly over a slice lets the compiler eliminate bounds checks and keep everything on the stack, because it knows exactly what kind of loop it's looking at. Ranging over an iter.Seq value is a call through a function value, and in the general case the compiler can't see through it to do the same optimisations. The special cases the standard library ships, like slices.Values, get inlined by the compiler often enough that the difference disappears in practice. A generic iterator you've written yourself and stored as an iter.Seq[T], passed around and composed with other iterators the way Filter was above, is much less likely to get inlined through several layers, so each element costs a genuine indirect call rather than a few inlined instructions.
In most code this doesn't matter: the overhead is on the order of an ordinary function call, and for anything doing real work per element (parsing a line, hitting a database, formatting output) it's noise. It starts to matter in tight numeric loops running many millions of times, where the difference between a range over a slice and a range over a composed chain of iterator adapters is measurable. Reach for iter.Seq for composability and laziness over things where allocation or memory would otherwise be the concern, not as a drop-in replacement for hot inner loops.
Pull-style iteration costs more, deliberately
Sometimes push-style iteration, where the producer calls you, isn't enough: merging two sorted sequences needs to compare the current head of each at the same time, which a callback-per-value protocol can't express directly. The iter package covers this with iter.Pull, which turns a push iterator into a pull one:
func Merge[V cmp.Ordered](a, b iter.Seq[V]) 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 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()
}
}
}
iter.Pull isn't implemented with a channel and a spawned goroutine, which is the naive way you'd fake pull-based iteration by hand. It uses the runtime's internal coroutine support, added specifically for this: a lightweight construct that switches directly between the caller and the suspended iterator without going through the scheduler. That makes it considerably cheaper than the channel version, but it's still not a function call: every next() is a context switch between two stacks. For the two-sequence merge above, that's two coroutine switches per comparison, which is fine for merging log streams or query results but not something you want in the innermost loop of a sort. It's also on you to call the returned stop function, or the coroutine leaks; defer stopA() right after the call to iter.Pull is the idiom for a reason.
None of this is an argument against using iter.Seq. It's a genuinely nice way to express lazy, composable sequences without allocating a slice up front or reaching for channels, and the standard library's adoption of it makes plain iteration over maps and slices in a stable key order or index order much less awkward than it used to be. The cost is concentrated in exactly the places you'd expect once you know the desugaring: non-local control flow, protocol misuse, and code that expects the compiler to see through an indirect call the way it sees through a slice.