How Pull Iterators Work in Go (iter.Pull)

iter.Pull converts a Go 1.23+ iterator into a PullIterator to enable step-by-step value consumption.

Bridging Push and Pull

You built a clean iterator using iter.Seq. It flows perfectly through a for range loop. Then you hit a wall. A third-party library demands a Next() method. Or you need to peek at the first value before deciding whether to process the rest. The push-based yield model of iter.Seq doesn't give you that control. You need to pull values on demand. iter.Pull turns your push-based sequence into a pull-based iterator.

The Tap and the Firehose

iter.Seq uses a callback style. You define a function that calls yield to send values out. The runtime drives the loop. This is efficient but opaque to the caller. The caller cannot pause the sequence, inspect a value without consuming it, or stop early without potentially leaving resources open.

iter.Pull wraps that sequence and exposes a Next() method. You call Next() to get the next value and a boolean indicating if the sequence is exhausted. It is the difference between a firehose spraying water and a tap you turn on and off. iter.Pull gives you the tap. You control the flow.

Minimal Example

Here is the simplest bridge: define a sequence, convert it to a pull iterator, and drive it manually.

package main

import (
	"fmt"
	"iter"
)

func main() {
	// Define a push-based sequence that yields numbers 0 through 4.
	// The yield function returns false to signal early termination.
	seq := func(yield func(int) bool) {
		for i := 0; i < 5; i++ {
			if !yield(i) {
				return
			}
		}
	}

	// Convert to a pull-based iterator.
	// Pull allocates a struct and spawns a goroutine to run the sequence.
	pull := iter.Pull(seq)
	// Close releases the goroutine and any resources held by the sequence.
	defer pull.Close()

	// Manually drive the iteration.
	for {
		// Next returns the value and a boolean.
		// ok is false when the sequence is exhausted.
		v, ok := pull.Next()
		if !ok {
			break
		}
		fmt.Println(v)
	}
}

How the Bridge Works

iter.Pull does more than wrap a function. It changes the execution model. When you call iter.Pull(seq), it returns a *iter.PullIterator. This struct holds a channel and a goroutine. The goroutine runs your sequence function. The channel buffers values between the sequence and the caller.

Calling pull.Next() blocks until the sequence calls yield. The yield call sends the value through the channel and waits for the caller to request the next item. This synchronization allows the caller to pause indefinitely without blocking the sequence goroutine. The sequence goroutine is suspended at the yield call, waiting for the next Next() invocation.

When you call pull.Close(), it sends a signal to the goroutine. The goroutine should detect this signal and exit. This is why Close() is essential. If the sequence function is infinite or blocks on I/O, Close() is the only way to stop it.

The cost of this mechanism is one goroutine per pull iterator. Goroutines are cheap, but they are not free. If you create millions of pull iterators in a tight loop, measure the allocation overhead. For typical application code, the cost is negligible.

Realistic Use: Peeking and Cleanup

Pull iterators shine when you need to inspect data before committing to a full iteration. A common pattern is a Head function that extracts the first element.

// Head returns the first value from a sequence, or zero value if empty.
// It returns the value and true if the sequence is non-empty.
func Head[T any](seq iter.Seq[T]) (T, bool) {
	// Bridge the sequence to allow manual control.
	pull := iter.Pull(seq)
	// Ensure cleanup even if the sequence allocates resources.
	defer pull.Close()

	// Fetch the first item. ok is false if the sequence is empty.
	v, ok := pull.Next()
	return v, ok
}

This pattern guarantees cleanup. If you used a range loop and broke early, the sequence function might not run to completion. If the sequence function holds a file handle or a database connection, breaking early could leak that resource. iter.Pull with defer pull.Close() ensures the sequence goroutine stops and resources are released.

The community convention is to always call Close() on a pull iterator. This mirrors the pattern for files and network connections. It signals that the iterator might hold resources. Even for simple sequences, writing defer pull.Close() makes the code robust against future changes to the sequence implementation.

Pitfalls and Errors

If you write v := pull.Next(), the compiler rejects this with multiple-value pull.Next() in single-value context. You must capture both the value and the boolean. The boolean tells you whether the sequence has more data. Ignoring it leads to silent errors where you process a zero value after the sequence ends.

iter.Pull is not thread-safe. The underlying sequence executes in the goroutine spawned by Pull. The Next() method communicates with that goroutine via a channel. Passing the pull iterator to another goroutine causes a data race. The pull iterator is tied to the goroutine that calls Next(). If you need to share data across goroutines, use a channel directly.

The worst goroutine bug is the one that never logs. If you forget to call Close(), the goroutine running the sequence may hang forever. This happens if the sequence is infinite or blocks on I/O. The goroutine consumes memory and stays in the runtime scheduler. Use defer pull.Close() immediately after creating the iterator. This prevents leaks even if the function returns early due to an error.

When to Use Pull Iterators

Use range over iter.Seq when you want to consume the entire sequence with minimal boilerplate.

Use iter.Pull when you need to inspect values before processing, peek at the first element, or bridge to an API that requires a Next() method.

Use iter.Pull when you must guarantee cleanup of resources held by the sequence function, even if iteration stops early.

Use a slice when the data fits in memory and you need random access or multiple passes.

Use a channel when you need to share the sequence across multiple goroutines safely.

Pull iterators give you control. Use that control to inspect, not to complicate. Always close the pull iterator. Resources don't vanish on their own.

Where to go next