The problem with loading everything upfront
You are processing a million-line log file. You only need the first ten entries that match a specific error code. Loading the entire file into a slice wastes memory and CPU cycles. A traditional for loop with an index works, but it forces you to manage the loop counter, the break condition, and the filtering logic in one tangled block. You want a clean way to generate values on demand, stop early without penalty, and keep the filtering logic separate from the consumption logic. Go 1.23 introduced iter.Seq and iter.Seq2 to solve exactly this.
How iter.Seq actually works
Think of an iterator as a vending machine instead of a bulk warehouse. A slice is a warehouse: you pay for the space upfront, stock every item, and then walk through the aisles to pick what you need. An iterator is a machine that only manufactures an item when you press the button. You press it, get one item, decide whether to press it again, and stop whenever you want. The machine never builds more than you actually consume.
In Go, iter.Seq[T] is a function type that takes a single callback parameter named yield. The range keyword handles the button pressing. When you write for v := range myIterator(), the compiler rewrites that loop to call your function, pass in its own yield implementation, and let your function drive the flow. iter.Seq2[K, V] follows the exact same pattern but hands over two values at a time instead of one. The yield callback returns a boolean. A true result tells your function to keep going. A false result means the consumer called break or finished the loop, so your function should stop immediately.
Iterators are lazy by design. They produce zero values until the range loop asks for the first one. This keeps memory usage flat and lets you chain transformations without materializing intermediate slices.
A minimal sequence
Here is the simplest sequence: a function that generates even numbers up to ten.
package main
import (
"fmt"
"iter"
)
// EvenNumbers returns an iterator that yields even integers from 0 to 9.
func EvenNumbers() iter.Seq[int] {
return func(yield func(int) bool) {
// The closure captures the yield callback for the entire loop.
for i := 0; i < 10; i++ {
if i%2 == 0 {
// yield returns false if the range loop breaks early.
if !yield(i) {
return
}
}
}
}
}
func main() {
for n := range EvenNumbers() {
fmt.Println(n)
}
}
The function returns another function. That inner function matches the iter.Seq[int] signature exactly. The yield parameter is the bridge between your generator and the range loop. Every time you call yield(i), the loop body executes with n set to i. The boolean return value is your escape hatch.
Keep your generator functions small and focused. The compiler inlines short closures automatically, but deeply nested logic hurts readability. Trust gofmt to handle the indentation of the closure. Argue about the algorithm, not the braces.
What happens under the hood
At runtime, the range statement does three things. First, it calls EvenNumbers() and gets back the closure. Second, it constructs its own yield function that knows how to assign the yielded value to n and jump back into the loop body. Third, it passes that yield function into your closure and starts execution.
Your closure runs the for loop. It hits yield(0). The range machinery assigns 0 to n, runs fmt.Println(n), and returns true to your closure. Your closure checks the true, continues to i = 1, skips the odd number, reaches i = 2, calls yield(2), and repeats. If the loop body contained a break, the range machinery would return false from yield. Your closure sees false, executes return, and tears down cleanly. No extra iterations run. No hidden state leaks.
This pattern shifts the burden of iteration from the consumer to the producer. The consumer writes a simple for loop. The producer controls the pacing, the filtering, and the termination. The compiler handles the wiring. The closure that captures yield may be promoted to the heap if the compiler cannot prove it escapes the function scope. In practice, the Go compiler is aggressive about stack allocation, and short-lived iterators rarely trigger heap pressure. Profile before optimizing.
Handling key-value pairs with iter.Seq2
Key-value iteration follows the same contract but expands the payload. iter.Seq2[K, V] expects a yield func(K, V) bool. This is the idiomatic way to wrap map iteration, database result sets, or configuration parsers without materializing intermediate slices.
Here is a realistic example: filtering a user map by age and yielding only the name and age pairs.
package main
import (
"fmt"
"iter"
)
// Adults returns an iterator yielding names and ages over 18.
func Adults(users map[string]int) iter.Seq2[string, int] {
return func(yield func(string, int) bool) {
// Iterate over the underlying map directly.
for name, age := range users {
if age > 18 {
// Pass both values to the consumer.
if !yield(name, age) {
return
}
}
}
}
}
func main() {
people := map[string]int{
"Alice": 22,
"Bob": 15,
"Carol": 30,
}
for name, age := range Adults(people) {
fmt.Printf("%s is %d\n", name, age)
}
}
The range loop automatically unpacks the two values into name and age. The iterator never creates a temporary slice of filtered users. It walks the map, checks the condition, and pushes matching pairs directly into the loop. Memory usage stays flat regardless of map size.
Go conventions favor explicit signatures over hidden behavior. The iter.Seq2 type makes the contract visible at the call site. Anyone reading the function signature knows exactly how many values will arrive on each iteration. Stick to lowercase receiver names if you attach this to a struct, and keep the yield parameter name exactly as yield to match standard library patterns.
Composing sequences without nesting
Writing nested closures for filtering, mapping, and chaining quickly becomes unreadable. The standard library provides combinators to keep your code flat. iter.Map, iter.Filter, and iter.Chain accept an iterator and return a new iterator. You pipe them together like Unix commands.
Here is how you transform the previous example using standard combinators.
package main
import (
"fmt"
"iter"
"slices"
)
// NamesOver18 returns an iterator of names filtered by age.
func NamesOver18(users map[string]int) iter.Seq[string] {
// Start with the raw map iterator.
raw := Adults(users)
// Filter keeps only entries where the predicate returns true.
filtered := iter.Filter(raw, func(name string, age int) bool {
return age > 18
})
// Map extracts just the name from the key-value pair.
return iter.Map(filtered, func(name string, age int) string {
return name
})
}
func main() {
people := map[string]int{
"Alice": 22,
"Bob": 15,
"Carol": 30,
}
for name := range NamesOver18(people) {
fmt.Println(name)
}
}
Each combinator returns a new iter.Seq or iter.Seq2. The pipeline executes lazily. iter.Map does not run until the range loop pulls a value. iter.Filter does not evaluate until iter.Map asks for the next item. This chaining pattern replaces manual index tracking and temporary buffers. Build small, testable pieces instead of one massive generator function.
Common traps and compiler feedback
Iterators look simple until you ignore the yield return value. If you write yield(i) without checking the boolean, your function will keep running even after the consumer breaks. This wastes CPU cycles and causes deadlocks if your iterator blocks on a network call or a channel. Always guard your yield calls with if !yield(...) { return }.
The compiler catches signature mismatches immediately. If you forget the type parameters and write func() iter.Seq, the compiler rejects it with iter.Seq is not a type. If you return a function with the wrong signature, you get cannot use func literal (value of type func(func(int) bool)) as iter.Seq[int] value in return statement. The error message is verbose but precise. Fix the return type or add the missing [T] bracket.
Infinite sequences require careful handling. An iterator that generates primes forever will hang the program if the consumer never breaks. Pair infinite iterators with iter.Take or iter.Drop from the standard library to bound the consumption. The standard library provides combinators like iter.Map, iter.Filter, and iter.Chain to compose sequences without writing nested closures. Build small, testable pieces instead of one massive generator function.
Goroutine leaks are a different beast but share the same root cause: waiting on a signal that never arrives. If your iterator spawns a background goroutine to fetch data and forgets to cancel it when yield returns false, that goroutine stays alive until the process exits. Always tie iterator lifecycles to explicit cancellation paths. Pass a context.Context as the first parameter to your generator if it performs I/O, and check ctx.Done() alongside the yield return value.
The worst iterator bug is the one that silently consumes memory. If you accidentally capture a large slice inside the closure and the closure outlives the loop, that slice stays pinned. Keep closures lean. Only capture what you need.
When to reach for iterators
Use iter.Seq when you need lazy evaluation for a single value stream and want to avoid allocating a temporary slice. Use iter.Seq2 when your data naturally arrives as key-value pairs and you want to filter or transform them on the fly. Use a plain slice when the dataset fits comfortably in memory and you need random access or length checks. Use a channel when you need concurrent producers, backpressure, or inter-goroutine communication. Use a traditional for loop with an index when you are iterating over a fixed-size array and the overhead of a closure outweighs the readability gain.
Iterators are plumbing. Keep them thin, keep them lazy, and let the range loop do the heavy lifting.