Iterators Instead of Channels in Go 1.23 and Later
Compare range-over-func iterators and channel generators in Go 1.23 and later: cost per item, early break, cancellation, iter.Pull and when channels win.

When Go 1.23 added range-over-func, one of the first questions was whether channels as generators were now obsolete. Not exactly. They solve different problems that happen to look alike in the simplest example, a loop that receives values from a function. A channel connects two goroutines. An iterator connects a producer and a consumer that run in the same goroutine. Once you separate those two jobs, most "generator" code turns out to want the second.
Tested with Go 1.25.6, Apple M1 Max (10 cores), macOS on arm64
This comparison puts both through the same four checks: what each costs per item, what happens when the consumer quits early, how each handles errors and cancellation, and what each is for. It follows generators built with goroutines, draws on range over func, and belongs to the concurrency patterns cluster. Everything ran on Go 1.25.6 on an Apple M1 Max.
The two shapes side by side
The same sequence of integers, written both ways:
package main
import "iter"
// countChan sends 0..n-1 from its own goroutine.
func countChan(n int) <-chan int {
ch := make(chan int)
go func() {
defer close(ch)
for i := range n {
ch <- i
}
}()
return ch
}
// countSeq yields 0..n-1 to its caller's loop.
func countSeq(n int) iter.Seq[int] {
return func(yield func(int) bool) {
for i := range n {
if !yield(i) {
return
}
}
}
}The channel version starts a goroutine and returns a channel. The iterator version returns a function and starts nothing: for v := range countSeq(5) calls that function, passing a yield that is the compiler-generated body of the loop. Each iteration of the loop body is a call to yield, running on the caller's stack. When the loop ends, breaks, returns or panics, the compiler arranges for yield to return false or the panic to propagate, and the iterator function unwinds normally. There is no second goroutine, so there is nothing to hand a value to.
Cost per item
I measured four ways of summing the first thousand integers: the channel generator, the iter.Seq, an iter.Pull pair (which gives pull-style access to a push iterator), and a plain slice loop for reference.
package main
import (
"iter"
"testing"
)
const N = 1000
func BenchmarkChannel(b *testing.B) {
for b.Loop() {
sum := 0
for v := range countChan(N) {
sum += v
}
_ = sum
}
}
func BenchmarkSeq(b *testing.B) {
for b.Loop() {
sum := 0
for v := range countSeq(N) {
sum += v
}
_ = sum
}
}
func BenchmarkPull(b *testing.B) {
for b.Loop() {
next, stop := iter.Pull(countSeq(N))
sum := 0
for {
v, ok := next()
if !ok {
break
}
sum += v
}
stop()
_ = sum
}
}
func BenchmarkSlice(b *testing.B) {
s := make([]int, N)
for i := range s {
s[i] = i
}
for b.Loop() {
sum := 0
for _, v := range s {
sum += v
}
_ = sum
}
}goos: darwin
goarch: arm64
pkg: example.test/lab
cpu: Apple M1 Max
BenchmarkChannel-10 7810 149500 ns/op 137 B/op 2 allocs/op
BenchmarkSeq-10 537756 2193 ns/op 56 B/op 3 allocs/op
BenchmarkPull-10 25551 47376 ns/op 224 B/op 7 allocs/op
BenchmarkSlice-10 3569449 333.0 ns/op 0 B/op 0 allocs/op
PASS
ok example.test/lab 5.031sOn this machine the channel took about 149,500 ns per thousand values, around 150 ns each. The iterator took about 2,190 ns, roughly 2 ns per value, which is 68 times less. iter.Pull came in at about 47,400 ns, around 47 ns per value: it runs the iterator on a separate coroutine and switches to it for each next, which is far cheaper than a channel hand-off but not free. The plain slice loop took about 333 ns, so even a well-inlined iterator costs several times a direct loop; the closure call per element is the price of the abstraction. Treat these as shapes rather than exact numbers: your CPU and Go version will change the constants, not the ordering.
The channel's cost is the scheduler. Each unbuffered send blocks the sender until the receiver arrives, so both goroutines park and wake once per value. That is worth it when the two sides do real work in parallel and not worth it for generating integers.
Quitting early
The leak in the channel version is the reason this comparison matters. A channel generator whose consumer stops reading leaves its goroutine parked on a send forever, unless the author threaded a context through and the consumer remembered to cancel it (the leak, reproduced). An iterator has no goroutine to leak. When the consumer breaks, yield returns false, and the iterator is required to return. A well-formed iterator checks the result:
func lines(r io.Reader) iter.Seq[string] {
return func(yield func(string) bool) {
sc := bufio.NewScanner(r)
for sc.Scan() {
if !yield(sc.Text()) {
return // consumer broke out of the loop
}
}
}
}If the iterator ignores the false and calls yield again, the runtime panics, which turns a latent bug into a loud one. Cleanup is also straightforward: a defer inside the iterator function runs when the loop exits by any route, so closing a file or releasing a lock happens exactly once, in the right place. Doing the same with a channel generator means designing a protocol for the consumer to signal the producer.
Errors and cancellation
Neither shape has a built-in error channel. With channels the convention is a struct carrying a value and an error, or a second channel for errors, both of which make the consumer's loop noisier. With iterators the idiom is iter.Seq2[T, error], which yields a pair on every step:
func readAll(paths []string) iter.Seq2[string, error] {
return func(yield func(string, error) bool) {
for _, p := range paths {
data, err := os.ReadFile(p)
if !yield(string(data), err) {
return
}
}
}
}
// for text, err := range readAll(files) { if err != nil { ... } }That reads naturally, and the consumer can decide whether to stop on the first error by breaking. Cancellation by context works the same way in both: the iterator can check ctx.Err() between items, or pass the context to whatever blocking call it makes. In a channel generator the select on ctx.Done() is part of the send; in an iterator it is a plain if at the top of the loop, because nothing else blocks. If the iterator wraps a blocking call (a network read), pass the context to that call, as you would anywhere (context cancellation).
iter.Pull: when you need next()
Range loops push: the iterator calls the loop body. Some code needs to pull: take one value, do something else, take another, or advance two sequences in lock step (merging two sorted streams). iter.Pull converts a push iterator into next and stop functions. It runs the iterator in a coroutine, a lightweight goroutine-like construct that the runtime switches into directly, with no scheduling delay. The contract has one sharp edge: you must call stop when finished, even if next has returned false, because stopping releases the coroutine. defer stop() right after the call is the habit. If you forget it for a sequence you abandon early, the coroutine is leaked, which is the iterator world's version of the channel leak.
Merging is the standard use:
func zip[A, B any](a iter.Seq[A], b iter.Seq[B]) iter.Seq2[A, B] {
return func(yield func(A, B) bool) {
nextA, stopA := iter.Pull(a)
defer stopA()
nextB, stopB := iter.Pull(b)
defer stopB()
for {
x, okA := nextA()
y, okB := nextB()
if !okA || !okB || !yield(x, y) {
return
}
}
}
}Two push iterators cannot be walked in lock step with range alone, which is exactly what Pull is for. At 47 ns per value it is a reasonable cost for the capability, and much cheaper than doing it with two channels.
Where channels are still the right answer
An iterator cannot run concurrently with its consumer, by construction: the loop body is a function called by the iterator, so the two alternate on the same goroutine. When the point is overlap, you need a goroutine and a channel.
- A slow producer and a slow consumer. Reading pages from an API while the previous page is processed. A buffered channel fills while the consumer works, and total time approaches the larger of the two instead of the sum.
- Many producers, one stream. Fan-in is a channel's job: each worker sends to the same channel and
rangedrains it (fan-out and fan-in). - Backpressure between stages. A pipeline of concurrent stages needs channels to connect them (pipelines).
- Events that arrive at unpredictable times. Timers, signals and network messages come in on their own schedule and are received with
select. An iterator has nothing to say about "or a timeout".
A useful test: does something other than the consumer's loop need to make progress while the loop body runs? If yes, channel. If no, iterator.
A decision list
For a sequence you can compute on demand, such as walking a tree, filtering a list, reading lines or paging a database cursor, write an iter.Seq or iter.Seq2. Callers get range, slices.Collect, maps.Keys and the rest of the standard library's iterator helpers for free. For work that should happen in parallel with its consumer, use a goroutine and a channel with a context. For lock-step access to several sequences, use iter.Pull with defer stop(). And when converting old code, the signal to look for is a goroutine that exists only to feed a range loop: those can usually become iterators and lose both the goroutine and the leak.
The language specification describes range over functions in full, the Go blog's range over function types post explains the design with examples, and the iter package documents Pull and the contract for well-behaved iterators.


