{"id":186,"date":"2026-05-04T05:04:40","date_gmt":"2026-05-04T05:04:40","guid":{"rendered":"https:\/\/abrarqasim.com\/blog\/go-iterators-six-months-of-range-over-functions\/"},"modified":"2026-05-04T05:04:40","modified_gmt":"2026-05-04T05:04:40","slug":"go-iterators-six-months-of-range-over-functions","status":"publish","type":"post","link":"https:\/\/abrarqasim.com\/blog\/go-iterators-six-months-of-range-over-functions\/","title":{"rendered":"Go Iterators: Six Months of range Over Functions in Real Code"},"content":{"rendered":"<p>Confession: when Go 1.23 shipped iterators last year, I read the release notes, said &ldquo;neat&rdquo;, and went right back to writing the same <code>for i := 0; i &lt; len(xs); i++<\/code> loops I&rsquo;d written for a decade. I told myself I&rsquo;d circle back. I didn&rsquo;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&rsquo;d read instead of avoiding the topic.<\/p>\n<p>If you&rsquo;re new to this: Go 1.23 added the ability to use <code>range<\/code> over a function value, plus a new standard library package called <code>iter<\/code>. That&rsquo;s the headline. The actual interesting bit is what it lets you do with the code you already have.<\/p>\n<h2 id=\"what-actually-changed-in-go-123\">What actually changed in Go 1.23<\/h2>\n<p>For years, the only things you could <code>range<\/code> 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.<\/p>\n<p>The <a href=\"https:\/\/go.dev\/blog\/go1.23\" rel=\"nofollow noopener\" target=\"_blank\">Go 1.23 release notes<\/a> added three new range-able forms: <code>func(yield func() bool)<\/code>, <code>func(yield func(V) bool)<\/code>, and <code>func(yield func(K, V) bool)<\/code>. The compiler turns your <code>for x := range myFunc<\/code> into a callback under the hood. You write loop code; the runtime calls your function with a <code>yield<\/code> it controls. If you stop iterating early (a <code>break<\/code>, a <code>return<\/code>, a <code>panic<\/code>), <code>yield<\/code> returns <code>false<\/code> and your producer cleans up. The mechanics are documented in the <a href=\"https:\/\/go.dev\/ref\/spec#For_range\" rel=\"nofollow noopener\" target=\"_blank\">Go language spec under For statements with range clause<\/a>, and the <a href=\"https:\/\/pkg.go.dev\/iter\" rel=\"nofollow noopener\" target=\"_blank\">iter package docs<\/a> describe the type aliases (<code>iter.Seq<\/code> and <code>iter.Seq2<\/code>) you&rsquo;ll see everywhere once you start using this.<\/p>\n<p>I ignored it because the proposal discussion (you can read <a href=\"https:\/\/github.com\/golang\/go\/issues\/61897\" rel=\"nofollow noopener\" target=\"_blank\">Russ Cox&rsquo;s original issue<\/a> if you want to see why it took so long) made it sound like a niche thing for library authors. It&rsquo;s not. It&rsquo;s for anyone who&rsquo;s ever written a paginated API client, a tree walker, or a database cursor wrapper.<\/p>\n<h2 id=\"the-before-and-after-that-finally-sold-me\">The before-and-after that finally sold me<\/h2>\n<p>Here&rsquo;s the rough shape of code I&rsquo;d written probably fifty times: pull pages of results from an API, do something with each item, stop when the caller&rsquo;s done. The pre-1.23 version always grew a <code>processFn<\/code> parameter that was awkward to test:<\/p>\n<pre><code class=\"language-go\">func EachOrder(ctx context.Context, c *Client, fn func(Order) error) error {\n    cursor := &quot;&quot;\n    for {\n        page, err := c.ListOrders(ctx, cursor)\n        if err != nil {\n            return err\n        }\n        for _, o := range page.Items {\n            if err := fn(o); err != nil {\n                return err\n            }\n        }\n        if page.NextCursor == &quot;&quot; {\n            return nil\n        }\n        cursor = page.NextCursor\n    }\n}\n<\/code><\/pre>\n<p>Not terrible, but the consumer can&rsquo;t <code>break<\/code> out cleanly, can&rsquo;t compose it with other iterators, and every test had to wrap a closure that captured a slice. With iterators it becomes:<\/p>\n<pre><code class=\"language-go\">func Orders(ctx context.Context, c *Client) iter.Seq2[Order, error] {\n    return func(yield func(Order, error) bool) {\n        cursor := &quot;&quot;\n        for {\n            page, err := c.ListOrders(ctx, cursor)\n            if err != nil {\n                yield(Order{}, err)\n                return\n            }\n            for _, o := range page.Items {\n                if !yield(o, nil) {\n                    return\n                }\n            }\n            if page.NextCursor == &quot;&quot; {\n                return\n            }\n            cursor = page.NextCursor\n        }\n    }\n}\n<\/code><\/pre>\n<p>The consumer side reads like the loop you wanted to write all along:<\/p>\n<pre><code class=\"language-go\">for o, err := range Orders(ctx, client) {\n    if err != nil {\n        return err\n    }\n    if o.Total &gt; threshold {\n        process(o)\n    }\n    if done {\n        break \/\/ producer cleans up; the cursor stops fetching\n    }\n}\n<\/code><\/pre>\n<p>That <code>break<\/code> is the thing that broke me out of my old habits. The old <code>EachOrder<\/code> callback couldn&rsquo;t really stop early without inventing sentinel errors. The new shape just works.<\/p>\n<h2 id=\"three-places-i-actually-reach-for-iterators-now\">Three places I actually reach for iterators now<\/h2>\n<p>Not every loop wants to be an iterator. The places where I keep reaching for them after six months:<\/p>\n<p><strong>Paginated API clients.<\/strong> Same as above. If your code already has a <code>for<\/code> loop wrapping <code>client.NextPage()<\/code>, an <code>iter.Seq2[T, error]<\/code> 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.<\/p>\n<p><strong>Tree and graph walks.<\/strong> The recursive variant of &ldquo;call this function on every node&rdquo; was always awkward in Go because you couldn&rsquo;t <code>return<\/code> from inside the callback to stop traversal. Now I write a <code>Walk<\/code> that&rsquo;s an <code>iter.Seq[*Node]<\/code>, and the caller decides when to stop. Cycle detection lives inside the producer, not in every consumer.<\/p>\n<p><strong>Lazy slices that might be huge.<\/strong> I had a place that loaded ~2M rows from Postgres for a nightly job. The old code accumulated everything into a <code>[]Row<\/code> and then iterated. Memory usage was awful. The iterator version streams: <code>for r := range db.AllRows(ctx)<\/code> pulls rows one at a time, the consumer decides when it has enough, and the producer closes the cursor on <code>break<\/code>. Peak memory dropped from a couple of gigs to under 50 MB.<\/p>\n<h2 id=\"where-i-still-wouldnt-bother\">Where I still wouldn&rsquo;t bother<\/h2>\n<p>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 (<code>iter.Seq2[K, V]<\/code>) are intimidating to people who&rsquo;ve never seen them. Three places I leave the old loops alone:<\/p>\n<p><strong>Tight inner loops on slices.<\/strong> If you&rsquo;ve already got a <code>[]int<\/code> of a known size and you&rsquo;re summing it, <code>for _, x := range xs<\/code> 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.<\/p>\n<p><strong>Single-use loops.<\/strong> If the code that produces values and the code that consumes them live in the same function and there&rsquo;s exactly one caller, just write the loop. The whole point of an iterator is to decouple producer from consumer. If they aren&rsquo;t decoupled, you&rsquo;re adding ceremony for nothing.<\/p>\n<p><strong>Concurrent fan-out.<\/strong> Iterators are sequential by design. If you need parallelism, channels are still the right primitive. I&rsquo;ve seen people try to wrap a <code>chan T<\/code> in an iterator and it always ends in pain because <code>break<\/code> from a range-over-func doesn&rsquo;t compose with select-case the way you&rsquo;d hope.<\/p>\n<h2 id=\"the-one-footgun-that-bit-me\">The one footgun that bit me<\/h2>\n<p>Here&rsquo;s the bug that cost me an afternoon and made me write this section.<\/p>\n<p>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 <code>range<\/code> loop ends. That&rsquo;s fine if the consumer iterates to completion or <code>break<\/code>s normally. It is not fine if the consumer panics. Specifically: when a panic unwinds through the <code>range<\/code> loop, the producer function gets a chance to run its deferred cleanup, but only if you actually wrote <code>defer<\/code> inside the iterator function. I had this:<\/p>\n<pre><code class=\"language-go\">func Rows(ctx context.Context, db *sql.DB, q string) iter.Seq2[Row, error] {\n    return func(yield func(Row, error) bool) {\n        rows, err := db.QueryContext(ctx, q)\n        if err != nil {\n            yield(Row{}, err)\n            return\n        }\n        defer rows.Close()  \/\/ I forgot this for two days\n        for rows.Next() {\n            var r Row\n            if err := rows.Scan(&amp;r); err != nil {\n                yield(Row{}, err)\n                return\n            }\n            if !yield(r, nil) {\n                return\n            }\n        }\n    }\n}\n<\/code><\/pre>\n<p>Without the <code>defer rows.Close()<\/code>, 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&rsquo;s easy to forget. Lesson: any iterator that opens a resource needs a <code>defer<\/code> inside the function literal, full stop.<\/p>\n<p>This is also where my <a href=\"https:\/\/abrarqasim.com\/blog\/go-concurrency-patterns-what-i-actually-ship\" rel=\"noopener\">Go concurrency patterns post<\/a> ends up relevant \u2014 the same discipline about defer-on-open-resource carries over, except now there&rsquo;s a second hidden control flow path (the <code>yield<\/code> returning false) you have to think about.<\/p>\n<h2 id=\"what-id-tell-past-me\">What I&rsquo;d tell past me<\/h2>\n<p>Don&rsquo;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.<\/p>\n<p>If you&rsquo;re building libraries, <a href=\"https:\/\/abrarqasim.com\/blog\/golang-generics-three-patterns-i-actually-use\" rel=\"noopener\">my Go generics patterns post<\/a> covers the type parameter side; <code>iter.Seq[T]<\/code> is just <code>func(yield func(T) bool)<\/code> with generics. Once you&rsquo;ve got both, most Go API design becomes &ldquo;return an iterator over T&rdquo; and the rest writes itself.<\/p>\n<h2 id=\"one-thing-to-do-this-week\">One thing to do this week<\/h2>\n<p>Find one place in your codebase where you&rsquo;ve got a function with a callback parameter named something like <code>fn func(T) error<\/code> or <code>each func(T)<\/code>. Rewrite it to return an <code>iter.Seq[T]<\/code> or <code>iter.Seq2[T, error]<\/code>. Run your tests. If they still pass, you&rsquo;re done; the consumers don&rsquo;t have to change because <code>for x := range it { ... }<\/code> just works with the new shape. If you want a sounding board on whether a specific bit of your code is worth converting, <a href=\"https:\/\/abrarqasim.com\" rel=\"noopener\">drop me a line<\/a> \u2014 I&rsquo;ve yet to regret a conversion, and I&rsquo;d rather you didn&rsquo;t waste an afternoon on a footgun I already paid for.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>I ignored Go 1.23 iterators for almost a year. Here&#8217;s what finally made me reach for them, where they&#8217;re worth it, and the footgun that bit me.<\/p>\n","protected":false},"author":2,"featured_media":185,"comment_status":"","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"","rank_math_description":"I ignored Go 1.23 iterators for almost a year. Here's what finally made me reach for them, where they're worth it, and the footgun that bit me.","rank_math_focus_keyword":"golang iterators","rank_math_canonical_url":"","rank_math_robots":"","footnotes":""},"categories":[147,159],"tags":[49,212,46,210,211,47,209],"class_list":["post-186","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-backend","category-go","tag-backend","tag-concurrency","tag-go","tag-go-1-23","tag-go-iter","tag-golang","tag-iterators"],"_links":{"self":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/186","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/comments?post=186"}],"version-history":[{"count":0,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/186\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media\/185"}],"wp:attachment":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media?parent=186"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/categories?post=186"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/tags?post=186"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}