Generators and Iterators Built With Goroutines in Go
Write a generator in Go with a goroutine and a channel, see how it leaks when the consumer stops early, fix it with context, then compare iter.Seq.

Python has yield. JavaScript has generator functions. Go had neither for a long time, and the community's answer was a goroutine that sends values on a channel: write the loop as if you were printing, replace the print with a send, and let the consumer pull values with range. The result reads well, runs the producer and the consumer in parallel, and has one trap that costs a lot of services a slow memory climb. This article shows the pattern, reproduces the trap with a number, and fixes it. It is part of the concurrency patterns cluster, builds on channels and closing channels, and ends with how the language's newer iterators compare. Everything ran on Go 1.25.6.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64)
The basic generator
A generator is a function that returns a receive-only channel. Inside, a goroutine produces values and closes the channel when it has none left, which is how the consumer's range loop knows to stop:
package main
import "fmt"
// Fibonacci returns a channel that yields the first n Fibonacci numbers.
func Fibonacci(n int) <-chan int {
ch := make(chan int)
go func() {
defer close(ch)
a, b := 0, 1
for range n {
ch <- a
a, b = b, a+b
}
}()
return ch
}
func main() {
for v := range Fibonacci(10) {
fmt.Print(v, " ")
}
fmt.Println()
}0 1 1 2 3 5 8 13 21 34That is the whole pattern. The channel is unbuffered, so each send waits for the consumer to receive, and the two goroutines run in lock step: the producer computes the next value only when the consumer is ready for it. That gives a natural form of backpressure and means the generator never runs ahead of the work being done with its output.
The signature is also part of the pattern. Returning <-chan int rather than chan int stops the caller from sending on or closing the channel. Only the generator closes it, because a channel has exactly one owner for that job (the close rules).
The leak
Now the consumer stops early. Maybe it found what it was looking for, maybe it hit an error. The break leaves the loop and the channel is abandoned with the producer still holding a value it is trying to send:
package main
import (
"fmt"
"runtime"
"time"
)
func Count() <-chan int {
ch := make(chan int)
go func() {
defer close(ch)
for i := 0; ; i++ {
ch <- i // blocks forever once nobody receives
}
}()
return ch
}
func main() {
before := runtime.NumGoroutine()
for range 1000 {
for v := range Count() {
if v == 3 {
break // found it, stop early
}
}
}
time.Sleep(50 * time.Millisecond)
fmt.Println("goroutines leaked:", runtime.NumGoroutine()-before)
}goroutines leaked: 1000A thousand abandoned generators leave a thousand goroutines parked on ch <- i, forever. Nothing will receive from those channels, nothing will close them, and the garbage collector cannot free a goroutine that is blocked, so each one holds its stack (a few kilobytes) and everything it references until the process exits. A request handler that does this once per request is a slow, steady memory leak that looks like load. The pattern gets its own article in goroutine leaks, and the leak detector described in goleak catches this in tests.
The cause is that the consumer is the only one who knows it is finished, and the channel gives it no way to say so. Closing a receive-only channel is not allowed, and wouldn't help: the producer is sending, not receiving.
The fix: a way to cancel
The generator needs a stop signal that the consumer controls. The idiomatic one is a context. The producer selects on ctx.Done() in the send, so cancellation unblocks it:
package main
import (
"context"
"fmt"
"runtime"
"time"
)
func Count(ctx context.Context) <-chan int {
ch := make(chan int)
go func() {
defer close(ch)
for i := 0; ; i++ {
select {
case ch <- i:
case <-ctx.Done():
return
}
}
}()
return ch
}
func main() {
before := runtime.NumGoroutine()
for range 1000 {
ctx, cancel := context.WithCancel(context.Background())
for v := range Count(ctx) {
if v == 3 {
break
}
}
cancel() // tell the generator to stop
}
time.Sleep(50 * time.Millisecond)
fmt.Println("goroutines leaked:", runtime.NumGoroutine()-before)
}goroutines leaked: 0The leak is gone: after the loop breaks, cancel() fires, the producer's select takes the Done case and returns, and the deferred close runs. The shape of the fix is a rule you can apply to every generator: the goroutine's every blocking operation must also select on cancellation. A generator that blocks on something else before the send, such as a network read, needs the same treatment there. The consumer's side is also a rule: whoever creates the context calls cancel, usually with defer cancel() right after creating it (context cancellation).
A done <-chan struct{} passed in works the same way, and for a generator that belongs to a larger operation, the caller's existing context is the right thing to pass rather than a fresh one.
What it costs
A generator over a channel pays for a goroutine switch on every value. For generators wrapping slow work such as reading lines from a socket or paging through an API, that is invisible. For a generator that produces numbers, it dominates. I measured summing the first thousand integers four ways on an Apple M1 Max:
package main
import (
"iter"
"testing"
)
const N = 1000
func countChan(n int) <-chan int {
ch := make(chan int)
go func() {
defer close(ch)
for i := range n {
ch <- i
}
}()
return ch
}
func countSeq(n int) iter.Seq[int] {
return func(yield func(int) bool) {
for i := range n {
if !yield(i) {
return
}
}
}
}
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 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 8385 140555 ns/op 137 B/op 2 allocs/op
BenchmarkSeq-10 548197 2185 ns/op 56 B/op 3 allocs/op
BenchmarkSlice-10 3653112 330.0 ns/op 0 B/op 0 allocs/op
PASS
ok example.test/lab 3.832sThe channel generator took about 140,600 ns for a thousand values, around 141 ns each. The same loop over an iter.Seq, which the next section introduces, took about 2,200 ns, and a plain slice loop about 330 ns. The channel is two orders of magnitude slower than the iterator, which is the price of two goroutines handing a value across a rendezvous. That cost does not make the pattern wrong; it tells you the producer should do enough work per value to hide it.
When to use a goroutine generator, and when not to
Use one when the producer and consumer should overlap in time. A producer that reads from a slow source (disk, network, an API with pagination) and a consumer that processes each item while the next is fetched is a real speedup, and a small buffer on the channel widens it. The pattern is also the first stage of a pipeline, where each stage is a generator reading from the previous one.
Do not use one when the work is pure computation and nothing runs concurrently with it. Since Go 1.23 the language has range-over-func: a function of type iter.Seq[T] takes a yield callback, and for v := range seq calls it. There is no goroutine and no channel, and break is handled by yield returning false, so nothing leaks. Range over func explains it, and iterators instead of channels compares the two in detail, including iter.Pull for the cases where you need pull-style access.
The decision is short. If a second goroutine would make the program faster, use a channel generator and give it a context. If not, use iter.Seq. Either way, check how every exit from the consumer reaches the producer, since that is the part that fails silently.
Checklist for a generator
The returned channel is receive-only. The generator closes it and nobody else does. The goroutine selects on a cancellation signal for every send and every other blocking call. The doc comment says who must cancel and when. And a test calls the generator, abandons it early and asserts that the goroutine count came back down, as the leak experiment above does. The Go blog's pipelines and cancellation article is where this convention comes from, and the iter package documentation describes the single-goroutine alternative.


