For over a decade, Go developers have abused channels. Lacking a standard iterator interface, we turned to buffered channels and goroutines to implement generator patterns. We treated chan T as an Iterator<T>, spawning goroutines just to iterate over paginated API responses, file lines, or database rows.
While syntactically elegant, this pattern is a performance trap. With the release of Go 1.23 and the standardization of the iter package (via the "range over func" experiment becoming standard), we can finally stop using concurrency primitives for sequential logic.
The Root Cause: The Hidden Cost of Channel Iteration
Using channels for simple iteration introduces unnecessary scheduler overhead and memory contention.
When you spawn a goroutine to feed a channel solely for iteration (the Producer-Consumer pattern applied to sequential data), you incur the following costs:
- Context Switching: Every time the channel buffer fills or empties, the Go runtime scheduler must park one goroutine and unpark another. Even with Go's lightweight scheduling, this context switch burns CPU cycles compared to a direct function call.
- Lock Contention:
hchan(the runtime structure for channels) is protected by amutex. Every send and receive operation acquires a lock. - Cache Misses: Passing data between goroutines increases the likelihood of L1/L2 cache misses compared to stack-allocated variables in a single execution flow.
- Leaky Abstractions: Error handling via channels is awkward. You either need a struct like
Result{Val, Err}, a secondary error channel, or you accept that errors get swallowed if the consumer stops reading early.
If your data processing does not require parallel execution, using channels is architectural overkill.
The Solution: Migrating to iter.Seq
Go 1.23 introduces standard function signatures for iterators in the iter package. This allows functions to be used directly in for...range loops without the overhead of channels.
The Legacy Approach (Channel-based)
Here is a typical "generator" pattern used in older Go codebases to stream paginated data.
package main
import (
"fmt"
"time"
)
type User struct {
ID int
Name string
}
// LegacyGenerator spawns a goroutine and uses a channel.
// BAD: High overhead for synchronous data traversal.
func LegacyGenerator(limit int) <-chan User {
out := make(chan User) // Unbuffered or small buffer
go func() {
defer close(out)
for i := 0; i < limit; i++ {
// Simulate db fetch latency
time.Sleep(1 * time.Millisecond)
// Blocking send
out <- User{ID: i, Name: fmt.Sprintf("User-%d", i)}
}
}()
return out
}
func main() {
// The consumer loops over the channel
for user := range LegacyGenerator(5) {
fmt.Printf("Received: %v\n", user)
}
}
The Modern Approach (iter.Seq)
Below is the rewritten implementation using iter.Seq2. We use Seq2 to return both the value and a potential error, which is the robust standard for fallible iterators.
This runs entirely on the caller's goroutine.
package main
import (
"fmt"
"iter"
"log"
"time"
)
type User struct {
ID int
Name string
}
// ModernGenerator returns an iterator sequence.
// GOOD: Zero allocation, no goroutine, compiler-optimized.
func ModernGenerator(limit int) iter.Seq2[User, error] {
return func(yield func(User, error) bool) {
for i := 0; i < limit; i++ {
// Simulate db fetch logic
time.Sleep(1 * time.Millisecond)
u := User{ID: i, Name: fmt.Sprintf("User-%d", i)}
// 'yield' pushes the data back to the range loop.
// It returns false if the caller breaks the loop.
if !yield(u, nil) {
return // Caller stopped iteration (break/return)
}
}
// Example of yielding an EOF or final error if needed,
// though typically nil error is implicit on return.
}
}
func main() {
// Go 1.23 native range support for iterators
for user, err := range ModernGenerator(5) {
if err != nil {
log.Fatalf("Iteration error: %v", err)
}
fmt.Printf("Received: %v\n", user)
}
}
Technical Breakdown
1. The yield Function
In ModernGenerator, the return type is iter.Seq2[User, error]. This is an alias for: func(yield func(User, error) bool)
The yield function passed into your closure represents the body of the caller's loop. When you call yield(u, nil), execution jumps to the inside of the for user, err := range ... block.
2. Stack vs. Heap
In the Channel approach, the User struct often escapes to the heap because it is passed between goroutines. In the Iterator approach, because yield is simply a function call, the compiler can often keep the User struct on the stack or inline the entire operation via mid-stack inlining, leading to zero allocations.
3. Flow Control
The yield function returns a bool.
true: The caller wants the next item (continue loop).false: The caller executedbreakorreturninside the loop.
This allows the iterator to perform cleanup (like closing a database connection) immediately when the loop terminates, without waiting for garbage collection or a context timeout.
4. Error Propagation
With channels, handling errors mid-stream is complex. You might close the channel (signaling success) when an error actually occurred, or send a zero-value struct. With iter.Seq2, the error is yielded directly to the loop, allowing standard if err != nil checks inside the range block.
Conclusion
Channels remain the correct tool for orchestrating concurrency—signaling events, fan-in/fan-out patterns, and passing ownership between worker pools.
However, for sequential data traversal—parsers, DB cursors, and paginated APIs—iter.Seq is the new standard. It removes the complexity of goroutine management, reduces CPU overhead, and results in code that is easier to read, test, and profile.
Action Item: Audit your codebase for functions returning <-chan T. If the channel is only used to range over data linearly, refactor it to iter.Seq[T].