Confession: when Go 1.23 shipped iterators last year, I read the release notes, said “neat”, and went right back to writing the same for i := 0; i < len(xs); i++ loops I’d written for a decade. I told myself I’d circle back. I didn’t. About six months ago a teammate sent me a 30-line refactor that gutted some of my ugliest plumbing into four lines, and I finally had to admit the feature was worth learning. This is the post I wish I’d read instead of avoiding the topic.
If you’re new to this: Go 1.23 added the ability to use range over a function value, plus a new standard library package called iter. That’s the headline. The actual interesting bit is what it lets you do with the code you already have.
What actually changed in Go 1.23
For years, the only things you could range over were arrays, slices, maps, channels, strings, and integers (the last one was 1.22). Anything else needed a callback function or a manually maintained iterator object. Both worked, neither read well.
The Go 1.23 release notes added three new range-able forms: func(yield func() bool), func(yield func(V) bool), and func(yield func(K, V) bool). The compiler turns your for x := range myFunc into a callback under the hood. You write loop code; the runtime calls your function with a yield it controls. If you stop iterating early (a break, a return, a panic), yield returns false and your producer cleans up. The mechanics are documented in the Go language spec under For statements with range clause, and the iter package docs describe the type aliases (iter.Seq and iter.Seq2) you’ll see everywhere once you start using this.
I ignored it because the proposal discussion (you can read Russ Cox’s original issue if you want to see why it took so long) made it sound like a niche thing for library authors. It’s not. It’s for anyone who’s ever written a paginated API client, a tree walker, or a database cursor wrapper.
The before-and-after that finally sold me
Here’s the rough shape of code I’d written probably fifty times: pull pages of results from an API, do something with each item, stop when the caller’s done. The pre-1.23 version always grew a processFn parameter that was awkward to test:
func EachOrder(ctx context.Context, c *Client, fn func(Order) error) error {
cursor := ""
for {
page, err := c.ListOrders(ctx, cursor)
if err != nil {
return err
}
for _, o := range page.Items {
if err := fn(o); err != nil {
return err
}
}
if page.NextCursor == "" {
return nil
}
cursor = page.NextCursor
}
}
Not terrible, but the consumer can’t break out cleanly, can’t compose it with other iterators, and every test had to wrap a closure that captured a slice. With iterators it becomes:
func Orders(ctx context.Context, c *Client) iter.Seq2[Order, error] {
return func(yield func(Order, error) bool) {
cursor := ""
for {
page, err := c.ListOrders(ctx, cursor)
if err != nil {
yield(Order{}, err)
return
}
for _, o := range page.Items {
if !yield(o, nil) {
return
}
}
if page.NextCursor == "" {
return
}
cursor = page.NextCursor
}
}
}
The consumer side reads like the loop you wanted to write all along:
for o, err := range Orders(ctx, client) {
if err != nil {
return err
}
if o.Total > threshold {
process(o)
}
if done {
break // producer cleans up; the cursor stops fetching
}
}
That break is the thing that broke me out of my old habits. The old EachOrder callback couldn’t really stop early without inventing sentinel errors. The new shape just works.
Three places I actually reach for iterators now
Not every loop wants to be an iterator. The places where I keep reaching for them after six months:
Paginated API clients. Same as above. If your code already has a for loop wrapping client.NextPage(), an iter.Seq2[T, error] is almost always cleaner. I wrap our internal Stripe-ish wrapper, our search index client, and a couple of GraphQL pagers this way. Every one of them got shorter.
Tree and graph walks. The recursive variant of “call this function on every node” was always awkward in Go because you couldn’t return from inside the callback to stop traversal. Now I write a Walk that’s an iter.Seq[*Node], and the caller decides when to stop. Cycle detection lives inside the producer, not in every consumer.
Lazy slices that might be huge. I had a place that loaded ~2M rows from Postgres for a nightly job. The old code accumulated everything into a []Row and then iterated. Memory usage was awful. The iterator version streams: for r := range db.AllRows(ctx) pulls rows one at a time, the consumer decides when it has enough, and the producer closes the cursor on break. Peak memory dropped from a couple of gigs to under 50 MB.
Where I still wouldn’t bother
Iterators are not free. The function-call overhead per element is real, the stack traces are uglier when something panics inside one, and the type signatures (iter.Seq2[K, V]) are intimidating to people who’ve never seen them. Three places I leave the old loops alone:
Tight inner loops on slices. If you’ve already got a []int of a known size and you’re summing it, for _, x := range xs is faster and more obvious than wrapping it in anything. I benchmarked a 10M-element sum and the iterator version was about 2x slower in 1.23 (closer to 1.4x on 1.24, the inliner got better). Not catastrophic, but not free.
Single-use loops. If the code that produces values and the code that consumes them live in the same function and there’s exactly one caller, just write the loop. The whole point of an iterator is to decouple producer from consumer. If they aren’t decoupled, you’re adding ceremony for nothing.
Concurrent fan-out. Iterators are sequential by design. If you need parallelism, channels are still the right primitive. I’ve seen people try to wrap a chan T in an iterator and it always ends in pain because break from a range-over-func doesn’t compose with select-case the way you’d hope.
The one footgun that bit me
Here’s the bug that cost me an afternoon and made me write this section.
If you write an iterator that holds a resource (database cursor, file handle, HTTP body), the cleanup runs when the iterator function returns, not when the range loop ends. That’s fine if the consumer iterates to completion or breaks normally. It is not fine if the consumer panics. Specifically: when a panic unwinds through the range loop, the producer function gets a chance to run its deferred cleanup, but only if you actually wrote defer inside the iterator function. I had this:
func Rows(ctx context.Context, db *sql.DB, q string) iter.Seq2[Row, error] {
return func(yield func(Row, error) bool) {
rows, err := db.QueryContext(ctx, q)
if err != nil {
yield(Row{}, err)
return
}
defer rows.Close() // I forgot this for two days
for rows.Next() {
var r Row
if err := rows.Scan(&r); err != nil {
yield(Row{}, err)
return
}
if !yield(r, nil) {
return
}
}
}
}
Without the defer rows.Close(), a panic in the consumer leaks the cursor. The old callback-style code had the same bug, but I caught it faster there because the cleanup was visually adjacent to the open call. With iterators the producer body is a separate function literal and it’s easy to forget. Lesson: any iterator that opens a resource needs a defer inside the function literal, full stop.
This is also where my Go concurrency patterns post ends up relevant — the same discipline about defer-on-open-resource carries over, except now there’s a second hidden control flow path (the yield returning false) you have to think about.
What I’d tell past me
Don’t wait six months. The feature is useful at week one if you spend twenty minutes converting one callback-style helper. Pick something small: a paginated client, a directory walker, a config parser that reads a stream. Convert it. The shape of the change is enough to make the rest of the API obvious.
If you’re building libraries, my Go generics patterns post covers the type parameter side; iter.Seq[T] is just func(yield func(T) bool) with generics. Once you’ve got both, most Go API design becomes “return an iterator over T” and the rest writes itself.
One thing to do this week
Find one place in your codebase where you’ve got a function with a callback parameter named something like fn func(T) error or each func(T). Rewrite it to return an iter.Seq[T] or iter.Seq2[T, error]. Run your tests. If they still pass, you’re done; the consumers don’t have to change because for x := range it { ... } just works with the new shape. If you want a sounding board on whether a specific bit of your code is worth converting, drop me a line — I’ve yet to regret a conversion, and I’d rather you didn’t waste an afternoon on a footgun I already paid for.