·3 min read·← All posts
Go iter.Seq Go 1.23

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:

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.

← Back to all posts