AlgoMaster Logo

Iterators

Medium Priority15 min readUpdated June 6, 2026

Go 1.23 introduced the iter package, a small but important addition that gives the language a standard vocabulary for iteration. The previous lesson on range-over-func showed how the compiler lets you range over a function value; this lesson covers the two named types from iter that those functions are expected to match, plus the helpers that turn a producer-driven loop into a consumer-driven one. The point of the package is alignment: once everyone agrees on iter.Seq and iter.Seq2, libraries can compose, filter, and chain iterators without each project inventing its own protocol.

The Two Sequence Types

The iter package is tiny. It exports two type aliases and two functions. The types are the foundation:

Seq[V] describes a sequence that yields one value at a time. Seq2[K, V] yields a pair on each step, which mirrors range over a map or a slice with index. Both types are functions. This is the whole representation: an iterator is a function that, when called with a yield callback, keeps invoking yield until the callback returns false or the function runs out of values.

This is called a push iterator because the producer (the function) pushes each value into the consumer (the yield callback) on its own schedule. When you write for v := range mySeq the compiler synthesizes the right yield and calls mySeq for you, so you almost never see the raw form. But seeing it once helps everything else click.

The body of CartPrices is the iterator. It walks the slice, hands each price to yield, and stops the moment yield returns false. The caller never sees that function directly because range writes the boilerplate for it, but the contract is that simple.

It helps to picture what the compiler does with for price := range CartPrices(). Roughly, it builds a yield function that runs the body of your range loop, passes that yield into the iterator, and arranges for yield to return false if your loop hits break or return. The diagram below shows the call structure:

The caller and the producer take turns. The producer yields a value, the caller runs one iteration of the loop body, then control returns to the producer for the next value. There is no goroutine, no channel, no scheduler. Iteration is just a function calling another function.

Seq2 works the same way with two values per step. A common shape is iter.Seq2[int, T] for an indexed sequence, or iter.Seq2[string, T] for something map-like.

The yield function returns a bool for a reason: it's the signal that the consumer is done. When the caller writes break or return inside the range loop, the compiler arranges for yield to return false on its next call, which is the producer's cue to clean up and exit. Ignoring that signal is the most common bug when writing iterators by hand, so always check the return value.

Push vs Pull: When to Use Which

Push iterators are convenient on the consumer side because you write a for loop. But there are cases where you need the opposite: the consumer wants to control timing, fetch one value at a time, do something between values, and only ask for the next one when ready. That's a pull iterator.

The iter package converts between the two with iter.Pull and iter.Pull2:

Pull takes a push iterator and gives you back two functions. next() returns the next value plus a boolean that's true while there are more values. stop() releases the resources the iterator is holding. You must call stop() when you're done, even if you've already drained the sequence, because the conversion runs the iterator on a separate goroutine internally and that goroutine needs a signal to exit.

The defer stop() is important. Without it, the iterator's hidden goroutine would sit blocked waiting for the next next() call, which never comes. That's a leak. The compiler doesn't catch it because pull iterators are just function values, so the discipline is on you.

A second subtle rule: don't call next from multiple goroutines at the same time. The conversion uses a small internal channel and isn't designed for concurrent callers. If you need two goroutines reading from the same source, give each one its own iterator or wrap next in your own mutex. For typical use, where one goroutine drives the loop, the rule never comes up.

iter.Pull spawns a goroutine and uses a small channel to bridge between push and pull. It's not free. If you can use the push form (for v := range seq), prefer it. Use Pull only when you actually need the consumer to drive timing.

The rule of thumb:

SituationUse
Standard for v := range seq walkPush (the default)
Merging two sorted streams, peeking at bothPull
Reading from an iterator inside a callbackPull
Stopping mid-iteration based on external statePush with break
Reading N items then handing the rest to another componentPull

A concrete pull use case: merging two sorted streams of orders into one. You can't write that with push iteration alone, because at each step you need to compare the heads of both sequences and consume only the smaller one.

The IDs aren't sorted in the output above. That's because the input streams in this example aren't sorted either; web jumps from 103 to 107 and app from 104 to 108 with 105 and 106 missing in both. The merge function still consumes in correct order based on what's at each head, but garbage in produces garbage out. Sort the inputs first, or build the producer to emit sorted output, and the merge gives a clean interleave. The bigger point stands: pull lets the consumer compare two sources side by side, which a pure push loop can't do.

Composing Iterators

Once iterators are just functions of a fixed shape, you can write generic helpers that take an iter.Seq and return a new one. This is where the package starts to feel powerful, because small reusable transforms compose into something close to a streaming API, without channels, without goroutines, and without copying data into a slice first.

Here are four helpers worth having around: Filter, Map, Take, and Zip. Each one is short, and each one preserves the early-exit signal correctly.

The helpers are lazy: nothing runs until the final for loop drives the chain. Each value moves through Filter, then MapSeq, then Take, one at a time, with no intermediate slice. And when Take decides it has emitted three values, it returns from its yield function, which propagates false back up the chain and tells Filter and the catalog to stop. No wasted work.

Zip pairs two iterators into an iter.Seq2. It stops when either input runs out:

Zip is the one helper here that has to use iter.Pull internally, because it needs to advance two independent sequences in lockstep. There's no way to do that with two pure push loops; one of them would always be in control. That tradeoff is fine: zip is rare in practice, so the per-call goroutine cost only shows up where the feature actually needs it.

Another helper that's worth having is Reduce. It folds an iterator down to a single value, the way you'd compute a cart total or the maximum price in a catalog.

Reduce is a strict consumer, it has to walk the whole sequence to produce its result, so it doesn't compose with infinite iterators. That's a useful contrast with Filter and Take, which stay lazy. When you see a helper that returns a single value instead of an iter.Seq, treat it as the end of the pipeline.

The diagram traces both directions of the pipeline. Values flow left to right as yield calls. The stop signal flows right to left as false return values, terminating the chain as soon as the consumer is satisfied. Every helper has to forward both, which is why each one checks yield's return value and exits when it sees false.

Building a Real Data-Source Iterator

The examples so far iterate over in-memory slices. The real win of iter.Seq is hiding fetching, paging, and resource management behind that same simple interface. A consumer just writes for order := range customer.Orders(), and the producer decides whether each value comes from a slice, a paginated API, a file, or something cached.

Here's an order history iterator that simulates page-by-page fetching from a backend. The consumer never sees the page boundaries; they just see a flat stream of orders.

The crucial property is that page 2 is never fetched if the consumer breaks out of the loop after order 1002. To see that, swap the loop body to break early and add a fmt.Println("fetching page", page) inside OrderHistory. The naive alternative, returning []Order containing the whole history, has to fetch everything up front whether the consumer wants it or not. The iterator form pays only for what gets consumed.

Pagination naturally maps to iterators because each page boundary is invisible to the consumer. Returning the full slice forces every caller to pay for every page, even ones it doesn't need. For a customer with 50 pages of order history and a UI that shows 10 orders at a time, that's a 5x reduction in data fetched.

Now combine the helpers from the previous section with this real iterator:

Only the first two pages are fetched. Page 3, which contains the order for $410.00, is never touched because Take(2) stopped the chain. This is the kind of behavior that's awkward to get right with eager fetching; with iterators, it falls out.

Iterators vs Channels

Before iter existed, the go-to way to produce a stream lazily was a channel plus a goroutine. That pattern still works, but iterators are usually a better choice when the consumer is in the same goroutine as the producer. Here's the same order history written with a channel, for comparison:

The output is right, but the program now has a leaked goroutine. The producer is stuck trying to send order 1003 on a channel that no one will ever read. To fix that with channels, you'd add a context.Context parameter, plumb a <-ctx.Done() into a select, and remember to cancel from the caller. That's three extra concepts and a real source of bugs.

The iterator version of the same code has none of that overhead. When the consumer breaks, yield returns false, the producer sees it on its next call, and returns. No goroutine, no channel, no leak.

ConcernChannel + goroutineiter.Seq
Cost per streamGoroutine stack, channel bufferNone beyond the function call
Leaks on early exitEasy to get wrongImpossible if yield's return is honored
Producer runs in parallelYes (separate goroutine)No (same goroutine as the loop)
BackpressureBuilt-in via blocking sendBuilt-in via yield's schedule
CancellationNeeds context.ContextThe false return is the signal
Good fit forProducer must run concurrently (network, multiplexing)Pure data sequences, transformations

Channels are appropriate when the producer genuinely needs to run concurrently, for instance, while waiting on a network call or fanning work out across multiple goroutines. For everything else, iterators are cheaper. A for v := range seq loop with a push iterator compiles down to direct function calls, no scheduler involvement.

There's one more sharp edge worth mentioning. If you write a producer that allocates a resource at the top (open a file, acquire a connection, start a transaction), put the release in a defer inside the iterator's function body, not in the function that builds the iterator. The iterator function doesn't run until the caller starts iterating, and releasing eagerly defeats the whole point.

The second form opens the file when iteration starts and closes it when the iterator function returns, whether through completion, an early false, or a break in the consumer. That's the property you want.

Quiz

Iterators Quiz

10 quizzes