The Python habit and the Go reality
You write Python every day. You love list(map(str, filter(lambda x: x > 0, numbers))). You open a Go file, see a for loop, and feel a pang of loss. You want to chain operations: filter the odds, map to strings, take the first ten. Go doesn't give you a .map() method on slices. It gives you something different. You have to build the chain yourself, or use the new iterator patterns from Go 1.23.
Go 1.23 added first-class support for iterators. The language didn't add methods to slices. It added a range over function syntax. The compiler now accepts a function that returns an iterator, and the loop drives the iteration. This design keeps Go's type system simple and avoids the overhead of iterator objects. You trade syntax sugar for explicit control flow.
Iterators are functions, not objects
An iterator in Go is a function with a specific signature: func(func(T) bool). It takes a callback and calls that callback to push values. The callback is named yield by convention. The range loop provides the yield function. Your iterator calls yield to send a value. The range loop runs your body, then asks for the next value.
Think of it as a factory assembly line. The range loop is the manager. The iterator is the worker. The manager hands the worker a conveyor belt (yield). The worker puts items on the belt one by one. The manager processes each item. If the manager stops, the worker sees the belt lock up and stops working.
The bool return value from yield is the signal. If yield returns true, the loop is still running. If yield returns false, the loop broke or returned. The iterator should stop calling yield and return immediately. This mechanism enables lazy evaluation. Values are computed only when the loop asks for them.
The yield handshake
Here's the building block: a function that yields values from a slice, skipping odds.
// ToSlice consumes an iterator and returns a slice for verification.
func ToSlice[T any](iter func(func(T) bool)) []T {
var result []T
iter(func(v T) bool {
result = append(result, v)
return true // keep consuming values
})
return result
}
// Evens yields only even numbers from the input slice.
func Evens(nums []int) func(func(int) bool) {
return func(yield func(int) bool) {
for _, n := range nums {
if n%2 == 0 {
if !yield(n) {
return // yield returned false, loop stopped
}
}
}
}
}
The Evens function returns a closure. That closure captures nums. When range calls the closure, it passes the yield function. The closure iterates nums. For each even number, it calls yield(n). If yield returns false, the closure returns. This stops the iteration early. The ToSlice helper shows how to consume an iterator manually. It calls the iterator with a yield that appends to a slice and always returns true.
Iterators are functions. Yield is the handshake.
Building a chain
Chaining means passing one iterator into another. The output of the filter becomes the input of the map. You write functions that accept an iterator and return a new iterator. The composition happens by nesting calls.
Here's how to build Map and Filter that work with iter.Seq.
// Map transforms values from one iterator to another type.
func Map[T, U any](src func(func(T) bool), f func(T) U) func(func(U) bool) {
return func(yield func(U) bool) {
src(func(v T) bool {
return yield(f(v)) // transform and pass yield result up
})
}
}
// Filter keeps values that satisfy the predicate.
func Filter[T any](src func(func(T) bool), pred func(T) bool) func(func(T) bool) {
return func(yield func(T) bool) {
src(func(v T) bool {
if pred(v) {
return yield(v)
}
return true // skip value, but keep consuming
})
}
}
The Map function calls src with a yield that transforms the value. It passes the result of yield(f(v)) back to src. This propagates the stop signal. If the outer loop breaks, yield returns false. Map returns false to src. src stops. The chain unwinds.
The Filter function checks the predicate. If true, it calls yield. If false, it returns true to src. This tells src to keep going. Skipping a value is not the same as stopping. The iterator must distinguish between "no value now" and "no more values ever".
Compose the chain by nesting the calls.
// Chain: filter evens greater than 2, map to strings, collect result.
result := ToSlice(Map(Filter(Evens([]int{1, 2, 3, 4}), func(x int) bool {
return x > 2 // filter evens greater than 2
}), func(x int) string {
return fmt.Sprintf("val-%d", x) // format as string
}))
// result is []string{"val-4"}
The call Filter(Evens(...), ...) returns an iterator. Map takes that iterator and returns a new one. ToSlice consumes the final iterator. The types flow through the pipes. Evens yields int. Filter yields int. Map yields string. The compiler checks the types at every step. If you pass the wrong type to Map, the compiler rejects it with cannot use f(v) (value of type string) as int value in argument to yield.
Chaining is just function composition. The types flow through the pipes.
Lazy evaluation and infinite streams
Iterators shine with infinite sequences because they are lazy. A slice must hold all values in memory. An iterator computes values on demand. You can represent an infinite stream of integers with a few lines of code.
Here's an iterator that yields integers starting from n forever.
// CountFrom yields integers starting from n until the loop stops.
func CountFrom(n int) func(func(int) bool) {
return func(yield func(int) bool) {
for {
if !yield(n) {
return // loop stopped, exit infinite loop
}
n++
}
}
}
The CountFrom function returns a closure with an infinite loop. It calls yield(n) and checks the result. If yield returns false, the closure returns. This breaks the infinite loop. Without the check, the goroutine would run forever. The range loop controls the lifetime of the iterator.
You can chain CountFrom with Filter and Take. The xiter package provides Take to limit consumption.
// Take yields the first n values from the source iterator.
func Take[T any](src func(func(T) bool), n int) func(func(T) bool) {
return func(yield func(T) bool) {
src(func(v T) bool {
if n > 0 {
n--
return yield(v)
}
return false // stop after n values
})
}
}
The Take function decrements a counter. When the counter hits zero, it returns false to src. This stops the source iterator. The chain terminates cleanly. You can process the first ten million primes without allocating a slice of ten million integers. The memory footprint stays constant.
Lazy evaluation saves memory. Infinite streams are safe. The loop holds the kill switch.
Pitfalls and silent failures
The compiler won't save you from logic errors. If you forget to call yield, the iterator produces nothing. The loop finishes instantly. No panic. Just empty output. This is the most common bug. You write an iterator, run the code, and see no results. You spent twenty minutes debugging a missing yield call.
If you call yield after returning, the compiler catches it. You get cannot use ... in return statement or a similar error about unreachable code. If you mix up types, you get cannot use f(v) (value of type string) as int value in argument to yield. These errors are clear. The silent bug is the empty iterator. Test your yields.
Another pitfall is goroutine leaks. If you start a goroutine inside an iterator and the loop breaks early, the goroutine might keep running. The iterator must cancel the goroutine when yield returns false. Use context.Context or a channel to signal cancellation.
// Bad: goroutine leaks if loop breaks.
func BadIterator() func(func(int) bool) {
return func(yield func(int) bool) {
go func() {
for i := 0; ; i++ {
yield(i) // yield might return false, but goroutine ignores it
}
}()
// iterator returns immediately, goroutine runs forever
}
}
The BadIterator starts a goroutine and returns. The range loop calls yield. The goroutine calls yield in a loop. If the loop breaks, yield returns false. The goroutine doesn't check the result. It keeps calling yield. The range loop is gone. The yield function is a no-op or panics depending on implementation. The goroutine leaks. Always respect the yield return value. Stop the goroutine when the loop stops.
Convention aside: the xiter package in the standard library provides safe utilities for common patterns. It includes Seq, Map, Filter, Take, Drop, and Reduce. You don't need to write these yourself. Use xiter when you need standard operations. Reimplementing common patterns adds noise to your codebase.
The silent bug is the empty iterator. Test your yields.
When to use iterators
Go gives you loops and iterators. Pick the right tool for the job.
Use a plain for loop when you need simple iteration over a slice or map. The overhead of iterator functions is unnecessary for basic traversal.
Use iter.Seq when you want lazy evaluation or infinite sequences. Iterators compute values on demand, which saves memory for large datasets.
Use helper functions like Map and Filter when you compose multiple transformations. Chaining iterators keeps the pipeline readable and modular.
Use xiter utilities when you need standard operations like Take, Drop, or Reduce. Reimplementing common patterns adds noise to your codebase.
Use range over a function directly when the iteration logic is unique to one call site. Wrapping a single-use loop in a named iterator function adds abstraction without benefit.
Loops are fast. Iterators are flexible. Pick the tool that matches the data flow.