What landed
Go 1.23 added for range over functions:
for v := range iterFunc {
// ...
}
Where iterFunc is a func(yield func(T) bool). The standard package iter adds iter.Seq[T] and iter.Seq2[K, V] as type aliases.
What this enables
Lazy iteration over computed sequences:
// All file paths under a directory, lazily
func WalkFiles(root string) iter.Seq[string] {
return func(yield func(string) bool) {
filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil { return err }
if info.IsDir() { return nil }
if !yield(path) {
return filepath.SkipDir
}
return nil
})
}
}
// Consumer
for path := range WalkFiles("./docs") {
// ...
if shouldStop { break }
}
The yield returns false when the consumer breaks. The iterator can stop work early. This is the part you couldn’t do with a slice-returning function.
Where it pays back
Infinite or very large sequences. Returning a slice means allocating it. With iter.Seq, you only pay for what you consume.
Composable transformations. Stream-style operations: filter, map, take. Each stage is an iter.Seq that consumes another.
import "iter"
func Filter[T any](seq iter.Seq[T], pred func(T) bool) iter.Seq[T] {
return func(yield func(T) bool) {
for v := range seq {
if pred(v) && !yield(v) { return }
}
}
}
func Map[T, U any](seq iter.Seq[T], f func(T) U) iter.Seq[U] {
return func(yield func(U) bool) {
for v := range seq {
if !yield(f(v)) { return }
}
}
}
// Compose
result := Filter(Map(WalkFiles("./"), filepath.Ext), func(ext string) bool {
return ext == ".go"
})
Cooperative cancellation. Combined with context.Context, the yield’s false-return + context cancellation gives you clean stops without panics or goroutine leaks.
Where it doesn’t help
Small fixed-size results. Returning []string is fine if there are 5 items and you’re going to use all of them. iter.Seq adds indirection without saving anything.
Concurrent producers. iter.Seq is single-goroutine; the function runs in the consumer’s goroutine. For producer-consumer with multiple producers, channels remain the right tool.
APIs that need slice-like operations. If the consumer wants len() or random access, give them a slice. iter.Seq doesn’t support either.
What I use it for
For Genie:
- Walking the agent registry:
iter.Seq[Agent]instead of[]Agent. - Streaming RAG chunks:
iter.Seq[Chunk]so the LLM call can start before all chunks are loaded. - Iterating audit log entries during verification: very large; lazy is correct.
For application-level “give me all the X” methods, slices are still the right return. For “stream me X as it comes,” iter.Seq is the right tool.
The migration
If you have existing code that uses callback-style “for-each” functions, they convert mechanically:
// Before
func ForEachUser(f func(User) error) error {
rows, _ := db.Query("SELECT ...")
defer rows.Close()
for rows.Next() {
var u User
rows.Scan(...)
if err := f(u); err != nil { return err }
}
return nil
}
// After
func Users() iter.Seq[User] {
return func(yield func(User) bool) {
rows, _ := db.Query("SELECT ...")
defer rows.Close()
for rows.Next() {
var u User
rows.Scan(...)
if !yield(u) { return }
}
}
}
The caller switches from ForEachUser(handle) to for u := range Users() { handle(u) } — and gets break for free.
Worth doing for hot paths. Worth doing in new code by default. Not worth a mass refactor of existing callback APIs that work.
iter.Seq is the right level of abstraction for streaming in Go now. Channels for concurrent; iter.Seq for sequential-streaming; slices for fixed-size collections.