Generator Pattern in Go: Lazy Sequences with Channels
In this piece
TL;DRYou need a sequence of values produced lazily, often infinite or async. A function returns a receive-only channel; a goroutine inside sends values and closes the channel when done. Use it for test data, log streams, iterators, and anywhere pulling values one at a time beats building a whole slice up front. Since Go 1.23, prefer
iter.Seqfor purely synchronous sequences — see the comparison below.
Introduction
The Generator pattern in Go is a powerful concurrency pattern used to create functions that produce a sequence of values. It leverages Go’s goroutines and channels to generate data asynchronously, providing an elegant way to work with streams of data or implement iterators.
When to Use
- Creating sequences of numbers or data
- Implementing iterators
- Processing streams of data
- Generating test data
- Simulating real-time data sources
Why to Use
- Lazy Evaluation: Values are generated on-demand, saving memory
- Encapsulation: Hides the complexity of data generation
- Concurrency: Allows for asynchronous data production
- Flexibility: Can generate infinite or finite sequences
- Composability: Generators can be chained or combined easily
How it Works
- A function creates and returns a channel
- The function starts a goroutine that sends values through the channel
- The caller receives values from the channel, typically using a for-range loop
Simple Example
func evenGenerator(max int) <-chan int {
out := make(chan int)
go func() {
for i := 0; i <= max; i += 2 {
out <- i
}
close(out)
}()
return out
}
func main() {
for num := range evenGenerator(10) {
fmt.Println(num)
}
}This example creates a generator that produces even numbers up to a specified maximum. The evenGenerator function returns a receive-only channel (<-chan int). It starts a goroutine that sends even numbers through the channel and closes it when done.
Real-World Example: Log Line Generator
Let’s consider a scenario where we need to generate sample log lines for testing a log analysis system.
type LogEntry struct {
Timestamp time.Time
Level string
Message string
}
func logGenerator(count int) <-chan LogEntry {
out := make(chan LogEntry)
go func() {
levels := []string{"INFO", "WARNING", "ERROR"}
messages := []string{
"User logged in",
"Failed login attempt",
"Database connection lost",
"API request received",
"Cache miss",
}
for i := 0; i < count; i++ {
out <- LogEntry{
Timestamp: time.Now().Add(time.Duration(i) * time.Second),
Level: levels[rand.Intn(len(levels))],
Message: messages[rand.Intn(len(messages))],
}
time.Sleep(100 * time.Millisecond) // Simulate delay between log entries
}
close(out)
}()
return out
}
func main() {
for entry := range logGenerator(5) {
fmt.Printf("[%s] %s: %s\n", entry.Timestamp.Format(time.RFC3339), entry.Level, entry.Message)
}
}This generator creates a stream of log entries, simulating real-world log generation. It’s useful for testing log processing systems, allowing developers to generate a controlled stream of diverse log entries.
Generators vs. iter.Seq (Go 1.23+)
Since Go 1.23, the standard library offers another way to build lazy sequences: range-over-func iterators (iter.Seq, iter.Seq2). An iterator is a function that yields values one at a time — no goroutine, no channel:
func evens(max int) iter.Seq[int] {
return func(yield func(int) bool) {
for i := 0; i <= max; i += 2 {
if !yield(i) {
return // consumer stopped early
}
}
}
}
func main() {
for n := range evens(10) {
fmt.Println(n)
}
}The two approaches overlap, but they are not interchangeable.
Reach for iter.Seq when:
- The consumer drives the pace and everything is synchronous
- You want to compose with the standard library (
slices.Collect,maps.Keys,slices.Sorted) - You want zero goroutine management — an iterator cannot leak
Keep the channel generator when:
- Values are produced concurrently or asynchronously — I/O, timers, network events
- You need a buffer for backpressure between producer and consumer
- The sequence feeds a concurrent pipeline. A generator channel plugs directly into a worker pool or a producer-consumer setup; an
iter.Seqdoes not.
A useful mental model: iter.Seq pulls, a channel generator pushes. If nothing in your program needs to happen at the same time as something else, pulling is simpler.
Best Practices and Pitfalls
Best Practices:
- Always close the channel when generation is complete
- Use receive-only channels (
<-chan) as return types - Consider using context for cancellation in long-running generators
- Implement error handling for robust generators
Pitfalls:
- Forgetting to close channels, leading to goroutine leaks
- Creating infinite generators without proper termination conditions
- Blocking indefinitely on channel operations without timeout mechanisms
- Overusing generators for simple, finite sequences where slices might suffice
Summary
The Generator pattern in Go provides a powerful way to create sequences or streams of data asynchronously. By leveraging goroutines and channels, it offers lazy evaluation, encapsulation of complex logic, and seamless integration with Go’s concurrency model. Whether you’re working with infinite sequences, simulating data sources, or implementing iterators, the Generator pattern offers a flexible and efficient solution.
Testing Generators
Generators are easy to test when you treat them as contracts: send input, receive output. The goroutine is an implementation detail. I wrote about why that matters in TDD Isn’t About Bugs — It’s Your Permission to Refactor.
Ready for bigger examples? Advanced Generator Patterns composes generators into test-data pipelines and streaming stages.
If you want to experiment with the code examples, you can find them on my GitHub repository.
Filed under