TL;DR

Go 1.23 shipped the iter package and range-over-func in August 2024, and after 20 months of real use the shape of idiomatic Go iterators is pretty settled. You write a function that takes a yield func(V) bool, you call yield(v) for each value, and you return the moment yield returns false — that’s the entire contract. But the yield signature has two traps that will bite you the first time you skip the return check, and the push-vs-pull distinction is trickier than most tutorials let on. This post walks through iter.Seq, iter.Seq2, early termination, iter.Pull, and the patterns the stdlib now uses in maps and slices.

Why iterators finally landed

For a decade Go had exactly one way to iterate: range over a built-in type (slice, map, string, channel, int). If you wanted to walk a custom tree, a paginated API, or an unbounded sequence, you either returned a slice (eager, allocating) or wrote a channel-based generator (leaky, slow). The community tried everything from callback methods to visitor interfaces, and none of it composed.

Go 1.22 (February 2024) shipped range-over-func as an experiment behind GOEXPERIMENT=rangefunc. Go 1.23 (August 2024) made it stable, added the iter package, and rewrote maps and slices to return iterators instead of slices. Go 1.24 through 1.26 kept refining the corners. By 2026 the feature is boring in the best way. The Go blog’s release post walks through the design rationale if you want the historical read.

The two types you need to know

The entire iter package fits in a dozen lines of signatures:

package iter

type Seq[V any]     func(yield func(V) bool)
type Seq2[K, V any] func(yield func(K, V) bool)

func Pull[V any](seq Seq[V])   (next func() (V, bool), stop func())
func Pull2[K, V any](seq Seq2[K, V]) (next func() (K, V, bool), stop func())

Seq[V] is the default. It yields one value per step, like ranging over []int or <-chan string. Seq2[K, V] yields two, like ranging over a map or an indexed slice. Go chose two separate types (instead of one generic tuple) because otherwise for i, v := range seq could never have worked cleanly with the existing range syntax. A small compromise that keeps for range readable.

Your first iterator

Here’s a counter that yields integers from start to end-1:

func Count(start, end int) iter.Seq[int] {
    return func(yield func(int) bool) {
        for i := start; i < end; i++ {
            if !yield(i) {
                return
            }
        }
    }
}

func main() {
    for n := range Count(0, 5) {
        fmt.Println(n)
    }
}

Three things to notice. First, Count returns a closure that captures start and end; that closure is your iterator. Second, the body is a plain loop you already know how to write. Third, the if !yield(i) { return } is the whole contract in a single line: each call to yield checks whether the consumer still wants more.

Skip that check and you have a bug. The consumer might break out of the for range, or call your iterator inside iter.Pull and abandon it early, or panic partway through. If you keep calling yield after it’s returned false, Go will panic with runtime error: range function continued iteration after function for loop body returned false. The runtime catches this explicitly, which is nice. You’ll see the problem the first time you test rather than shipping a silent resource leak.

Seq2: when you need two values

A tree traversal that yields (depth, value) pairs:

type Node[T any] struct {
    Value       T
    Left, Right *Node[T]
}

func (n *Node[T]) Walk() iter.Seq2[int, T] {
    return func(yield func(int, T) bool) {
        var visit func(*Node[T], int) bool
        visit = func(n *Node[T], depth int) bool {
            if n == nil {
                return true
            }
            if !visit(n.Left, depth+1) {
                return false
            }
            if !yield(depth, n.Value) {
                return false
            }
            return visit(n.Right, depth+1)
        }
        visit(n, 0)
    }
}

Usage:

for depth, v := range root.Walk() {
    fmt.Printf("%*s%v\n", depth*2, "", v)
}

Recursion + iterators is where the return bool pattern pays off. Each recursive call forwards whatever yield returned, so a break three levels deep unwinds all the way up without any goroutines, channels, or shared state. The Go blog’s binary tree example uses the same trick.

Early termination is the real point

Slices are fine when you know the sequence is small. Iterators earn their keep when it’s big or unbounded.

// Return the first matching line without reading the whole file.
func FirstMatch(lines iter.Seq[string], pattern string) (string, bool) {
    for line := range lines {
        if strings.Contains(line, pattern) {
            return line, true
        }
    }
    return "", false
}

The return inside the for range triggers the iterator’s internal yield to return false, which is how the iterator knows to clean up and exit. If lines is wrapping a file handle, an HTTP paginator, or a database cursor, the producer can close its resources on that path, assuming it was written with the contract in mind.

1.23
Go version shipped iter stable
2
Types in the package: Seq, Seq2
20
Months of stable real-world use

Composing iterators: filter and map

You won’t find Filter or Map in the stdlib. The Go team decided those belong in userland, which is fine, because they’re three lines each.

func Filter[V any](seq iter.Seq[V], pred func(V) bool) iter.Seq[V] {
    return func(yield func(V) bool) {
        for v := range seq {
            if pred(v) && !yield(v) {
                return
            }
        }
    }
}

func Map[A, B any](seq iter.Seq[A], fn func(A) B) iter.Seq[B] {
    return func(yield func(B) bool) {
        for a := range seq {
            if !yield(fn(a)) {
                return
            }
        }
    }
}

Now you can do Map(Filter(Count(0, 1000), even), square) and get a lazy pipeline that only does work as the consumer pulls. Each stage re-checks yield’s return value, so a break in the outer loop propagates through the whole chain. No allocation, no goroutines.

Push vs pull, and when you need iter.Pull

Range-over-func gives you a push iterator: the producer drives the loop and pushes values into yield. Most Go code wants push, because it matches how for range reads.

But sometimes you need to pull. The canonical example is comparing two sequences element-by-element:

func Equal[V comparable](a, b iter.Seq[V]) bool {
    nextA, stopA := iter.Pull(a)
    defer stopA()
    nextB, stopB := iter.Pull(b)
    defer stopB()

    for {
        va, okA := nextA()
        vb, okB := nextB()
        if okA != okB {
            return false
        }
        if !okA {
            return true
        }
        if va != vb {
            return false
        }
    }
}

You cannot do this with two nested for range loops, because they’d each want to drive their own control flow. iter.Pull flips the push iterator inside out and hands you a next() function that blocks until the underlying iterator yields a value. The defer stop() is not optional: if you return from Equal without stopping both pull iterators, the coroutines that back them will leak.

Under the hood, Pull spins up a runtime coroutine (not a goroutine — the implementation uses runtime.newcoro/coroswitch rather than go plus channels, which is a lot cheaper), runs your push iterator inside it, and blocks each yield call until the consumer asks for the next value. That coordination still costs real cycles. Pull is noticeably slower per element than raw push iteration in my benchmarks on Go 1.26 — usually a few times slower on a tight loop — so reach for it on lockstep comparisons and other genuinely pull-shaped problems, and skip it otherwise.

What the stdlib does now

Go 1.23 added iterator methods across the stdlib, which is the cleanest way to learn the idiom by example:

FunctionReturnsNotes
slices.All(s)iter.Seq2[int, V]index + value, replaces for i, v := range s when you need composition
slices.Values(s)iter.Seq[V]values only
slices.Sorted(seq)[]Vcollects an iterator into a sorted slice
maps.All(m)iter.Seq2[K, V]unordered key-value pairs
maps.Keys(m)iter.Seq[K]keys only
maps.Values(m)iter.Seq[V]values only

The pattern slices.Sorted(maps.Keys(m)) (get all keys, sort them) is the compact replacement for the old five-line “copy keys into a slice, sort, return” dance. Read the slices and maps package docs for the full list. Every method that used to return []T now has a sibling that returns an iterator.

Three mistakes I keep seeing

Forgetting the !yield check. Classic bug: you write yield(v) inside a loop, forget to test the return, and the consumer can’t break out. Go will panic loudly on the second call after yield returned false, but only if a consumer actually stops early. In tests that consume the full sequence the bug is invisible. Write the check every time, even when “the sequence is always finite.”

Holding resources across a user-controlled yield. If your iterator opens a file, acquires a lock, or starts a transaction, the consumer can break at any point. Use defer inside the iterator body for cleanup. It runs whether the loop completed, broke early, or panicked:

func Lines(path string) iter.Seq[string] {
    return func(yield func(string) bool) {
        f, err := os.Open(path)
        if err != nil {
            return
        }
        defer f.Close()
        s := bufio.NewScanner(f)
        for s.Scan() {
            if !yield(s.Text()) {
                return
            }
        }
    }
}

Pull without defer stop(). Every iter.Pull call spins up a runtime coroutine. Forget the stop() and you’ve written a coroutine leak that won’t surface until production — and it won’t show up in a goroutine profile the way a classic leak does, which makes it harder to spot. The pattern is always next, stop := iter.Pull(seq); defer stop() on the line after, no exceptions.

Error handling

The iter.Seq signature has no error channel, which bothers people at first. Three patterns cover the real cases:

  1. Seq2 with an error second value: iter.Seq2[T, error]. The consumer writes for v, err := range things { if err != nil { ... } }. Good for streams where most items succeed and errors are occasional.
  2. A result type: define type Result[T any] struct { V T; Err error } and yield Result[T]. More ceremony, but composes cleanly with Filter/Map.
  3. An error getter on the iterator: run the loop, then call it.Err() afterwards, the same pattern bufio.Scanner uses. Works when errors are terminal rather than per-item.

Option 3 is the precedent in the stdlib (bufio.Scanner), and option 1 is what most community database and HTTP-pagination wrappers have settled on post-1.23. Neither is strictly better.

FAQ

How do you write a custom iterator in Go?

Return a function of type iter.Seq[V], which is func(yield func(V) bool). The function should call yield(v) for each value and return the moment yield returns false. Wrap that function in a closure or a method so it captures whatever state it needs to walk the sequence.

What is the difference between Seq and Seq2?

Seq[V] yields one value per step; Seq2[K, V] yields two. Use Seq2 when you need pairs like (index, value) or (key, value), because for k, v := range seq2 requires exactly this type.

What is range-over-func in Go?

It’s the feature added in Go 1.22 (experimental) and Go 1.23 (stable) that lets for range work on a function of type iter.Seq or iter.Seq2. The runtime translates the loop body into the yield function at compile time, so there’s no hidden goroutine for push iterators.

How do you convert a push iterator to a pull iterator?

Call iter.Pull(seq). It returns (next, stop), where next() pulls one value at a time and stop() cleans up. Always defer stop() because Pull starts a goroutine you have to unwind.

Do iterators allocate?

A push iterator typically doesn’t — the compiler inlines the closure body into the caller and the yield function into the loop, so a hot-path for v := range seq is often as fast as a direct for i := 0; i < n; i++. iter.Pull does cost (one runtime coroutine plus a switch per value), which is why you’d only reach for it when you genuinely need pull semantics.

Sources

Bottom line

Iterators close the one structural gap the language had — letting a caller walk a custom collection without allocating — and they do it with syntax that fits the existing for range vocabulary. They add one new idiom, not a functional-programming layer. Twenty months in, I reach for iter.Seq any time I’d have returned a slice before, and I’ve stopped writing channel-based generators entirely. The takeaway is small and concrete: learn the yield contract, defer your cleanups, and skip iter.Pull unless you genuinely need to drive two sequences in lockstep.