{"id":188,"date":"2026-05-04T05:04:56","date_gmt":"2026-05-04T05:04:56","guid":{"rendered":"https:\/\/abrarqasim.com\/blog\/golang-iterators-range-over-func-eight-months-in\/"},"modified":"2026-05-04T05:04:56","modified_gmt":"2026-05-04T05:04:56","slug":"golang-iterators-range-over-func-eight-months-in","status":"publish","type":"post","link":"https:\/\/abrarqasim.com\/blog\/golang-iterators-range-over-func-eight-months-in\/","title":{"rendered":"Golang Iterators 8 Months In: What I Actually Use"},"content":{"rendered":"<p>Confession: when Go 1.23 added range-over-function iterators in August 2024, I hated them on sight. They looked like a Python comprehension wearing a fake Go mustache, and I was already happy iterating with <code>for<\/code> and slices like a normal person. Eight months later I have rewritten three internal libraries to expose iterators, and the new pattern is the second-best language change Go has shipped in years (generics is still first; fight me).<\/p>\n<p>This is a notes-from-production post, not a tutorial. If you want the spec, the official <a href=\"https:\/\/go.dev\/doc\/go1.23#iterators\" rel=\"nofollow noopener\" target=\"_blank\">Go 1.23 release notes<\/a> cover the language change in two paragraphs and the <a href=\"https:\/\/pkg.go.dev\/iter\" rel=\"nofollow noopener\" target=\"_blank\">iter package docs<\/a> explain the <code>Seq<\/code> and <code>Seq2<\/code> types in another two. What I want to write down is which patterns earned a place in my code, which ones I tried and threw away, and the specific places where the new syntax is a sharp edge.<\/p>\n<h2 id=\"the-one-shape-i-write-90-of-the-time\">The one shape I write 90% of the time<\/h2>\n<p>Most of my custom iterators look exactly the same. They wrap a paged or streamed data source so the caller doesn&rsquo;t have to think about pagination at all. Here is the before-and-after for our internal Stripe-style cursor pagination helper.<\/p>\n<p>Before (Go 1.22 and earlier), a caller had to write the loop themselves and remember to pass the cursor along:<\/p>\n<pre><code class=\"language-go\">cursor := &quot;&quot;\nfor {\n    page, err := client.ListInvoices(ctx, ListOptions{Cursor: cursor, Limit: 100})\n    if err != nil {\n        return err\n    }\n    for _, inv := range page.Items {\n        if err := process(inv); err != nil {\n            return err\n        }\n    }\n    if page.NextCursor == &quot;&quot; {\n        break\n    }\n    cursor = page.NextCursor\n}\n<\/code><\/pre>\n<p>Three things in that snippet are easy to get wrong: forgetting to update <code>cursor<\/code>, forgetting to break on the empty next-cursor, and accidentally returning early without draining the page. I have shipped each of those bugs at least once.<\/p>\n<p>With range-over-func, the iterator owns the pagination and the caller writes a regular <code>for<\/code> loop:<\/p>\n<pre><code class=\"language-go\">func (c *Client) Invoices(ctx context.Context) iter.Seq2[Invoice, error] {\n    return func(yield func(Invoice, error) bool) {\n        cursor := &quot;&quot;\n        for {\n            page, err := c.ListInvoices(ctx, ListOptions{Cursor: cursor, Limit: 100})\n            if err != nil {\n                yield(Invoice{}, err)\n                return\n            }\n            for _, inv := range page.Items {\n                if !yield(inv, nil) {\n                    return\n                }\n            }\n            if page.NextCursor == &quot;&quot; {\n                return\n            }\n            cursor = page.NextCursor\n        }\n    }\n}\n\n\/\/ Caller:\nfor inv, err := range c.Invoices(ctx) {\n    if err != nil {\n        return err\n    }\n    if err := process(inv); err != nil {\n        return err\n    }\n}\n<\/code><\/pre>\n<p>What I love about this is the caller code is shorter and the cancellation contract is now a single line: a normal <code>break<\/code> in the caller&rsquo;s loop tells <code>yield<\/code> to return <code>false<\/code>, which exits the producer. No sentinel, no <code>Close()<\/code>. I have one of these wrappers for every paginated API we hit. The pattern composes well with the <a href=\"https:\/\/abrarqasim.com\/blog\/golang-generics-three-patterns-i-actually-use\" rel=\"noopener\">generics work I wrote about earlier<\/a> \u2014 the iterator is generic in <code>Invoice<\/code> and gets dropped into a generic <code>Collect<\/code> helper without ceremony.<\/p>\n<h2 id=\"the-pattern-i-tried-and-dropped-pure-functional-pipelines\">The pattern I tried and dropped: pure-functional pipelines<\/h2>\n<p>A few weeks in I went through a phase of writing things like <code>Filter(Map(Take(seq, 100), parse), valid)<\/code>. It looks elegant. It is also a nightmare to debug because the iterator doesn&rsquo;t actually run until something pulls on it, so a panic shows up in a stack trace that bears no resemblance to where you defined the pipeline.<\/p>\n<p>More importantly, my coworkers hated reading it. We&rsquo;re a Go shop. We&rsquo;re here because we want flat code we can step through with a debugger. A six-line <code>for<\/code> loop with explicit conditionals is more honest than a one-liner that hides three closures. After two PRs of pushback I deleted my <code>xiter<\/code> helpers and went back to writing the loop. The pipeline aesthetic belongs in a language that was built for it, like Rust or Haskell. (For what it&rsquo;s worth, the Go team is being <a href=\"https:\/\/github.com\/golang\/go\/issues\/61898\" rel=\"nofollow noopener\" target=\"_blank\">conservative about adding xiter to the standard library<\/a> for similar reasons.)<\/p>\n<p>The rule I follow now: a custom iterator is worth it when it hides <em>real complexity<\/em> (pagination, streaming, tree traversal). It is not worth it as a stylistic preference over a clear loop.<\/p>\n<h2 id=\"where-the-syntax-bit-me-early-return-in-yield\">Where the syntax bit me: early <code>return<\/code> in <code>yield<\/code><\/h2>\n<p>The <code>bool<\/code> return value from <code>yield<\/code> exists so the caller can stop iteration early. Here is where I lost an afternoon. I wrote this:<\/p>\n<pre><code class=\"language-go\">func Walk(root *Node) iter.Seq[*Node] {\n    return func(yield func(*Node) bool) {\n        var visit func(n *Node)\n        visit = func(n *Node) {\n            if n == nil {\n                return\n            }\n            yield(n)              \/\/ BUG: ignored return value\n            visit(n.Left)\n            visit(n.Right)\n        }\n        visit(root)\n    }\n}\n<\/code><\/pre>\n<p>Spot the bug: I&rsquo;m not checking what <code>yield<\/code> returned, so a caller&rsquo;s <code>break<\/code> does nothing on a recursive walk. The iterator keeps producing values and the loop keeps ignoring them. CPU pegged at 100%, a <code>for<\/code> loop that looked correct, and a producer that never stopped. The fix is to thread the bool back up:<\/p>\n<pre><code class=\"language-go\">func Walk(root *Node) iter.Seq[*Node] {\n    return func(yield func(*Node) bool) {\n        var visit func(n *Node) bool\n        visit = func(n *Node) bool {\n            if n == nil {\n                return true\n            }\n            if !yield(n) {\n                return false\n            }\n            return visit(n.Left) &amp;&amp; visit(n.Right)\n        }\n        visit(root)\n    }\n}\n<\/code><\/pre>\n<p>Any recursive iterator needs this shape. The compiler will not save you. There is no <code>errcheck<\/code>-style linter for the yield bool that I&rsquo;ve found, and <code>go vet<\/code> doesn&rsquo;t flag it. Code review is your only defense, so I added &ldquo;check yield return&rdquo; to our review checklist.<\/p>\n<h2 id=\"composition-with-channels-dont-mostly\">Composition with channels: don&rsquo;t, mostly<\/h2>\n<p>The most common bad-idea question I see online is &ldquo;can I bridge a channel into an iterator?&rdquo; Yes, mechanically:<\/p>\n<pre><code class=\"language-go\">func Chan[T any](ch &lt;-chan T) iter.Seq[T] {\n    return func(yield func(T) bool) {\n        for v := range ch {\n            if !yield(v) {\n                return\n            }\n        }\n    }\n}\n<\/code><\/pre>\n<p>This works. It is also a footgun, because the iterator does not own the channel. If the caller breaks early, the producer goroutine on the other end of the channel is still pumping values into a buffer no one is reading. You end up with a goroutine leak that looks fine in tests and shows up in production after a week.<\/p>\n<p>If you really need a channel-backed iterator, pass a <code>context.Context<\/code> so the producer can cancel cleanly on caller exit. I covered the broader pattern in my post on <a href=\"https:\/\/abrarqasim.com\/blog\/go-concurrency-patterns-what-i-actually-ship\" rel=\"noopener\">Go concurrency patterns I actually ship<\/a> \u2014 the same advice applies here. The iterator is a consumer interface, not a substitute for <code>select<\/code> and <code>done<\/code> channels.<\/p>\n<h2 id=\"the-slices-and-maps-packages-got-nicer\">The slices and maps packages got nicer<\/h2>\n<p>The quiet win of the iterator update is what it did to the standard library. <code>maps.Keys<\/code> used to allocate a slice; now it returns a <code>Seq<\/code> and you can range over it without paying for the allocation:<\/p>\n<pre><code class=\"language-go\">for key := range maps.Keys(m) {\n    if strings.HasPrefix(key, &quot;x-&quot;) {\n        process(key)\n    }\n}\n<\/code><\/pre>\n<p>Same for <code>slices.Values<\/code>, <code>slices.All<\/code>, <code>slices.Backward<\/code>. I use <code>slices.Backward<\/code> constantly when I&rsquo;m walking a slice for safe deletion:<\/p>\n<pre><code class=\"language-go\">for i, v := range slices.Backward(items) {\n    if shouldDelete(v) {\n        items = slices.Delete(items, i, i+1)\n    }\n}\n<\/code><\/pre>\n<p>That used to be a manual reverse <code>for i := len(items)-1; i &gt;= 0; i--<\/code> loop. The new version reads the way it should.<\/p>\n<h2 id=\"when-i-still-skip-iterators\">When I still skip iterators<\/h2>\n<p>Three cases where I write a plain function instead:<\/p>\n<p>First, when the producer is genuinely synchronous and small. A function returning <code>[]string<\/code> is simpler than a <code>Seq[string]<\/code> for ten elements. Allocating a slice of ten strings costs nothing meaningful.<\/p>\n<p>Second, when I want the result twice. Iterators are single-shot by convention \u2014 running through a <code>Seq<\/code> twice means the producer runs twice, which can mean two API calls, two database queries, two file reads. If callers might want to iterate more than once, a slice is honest about the cost.<\/p>\n<p>Third, in tests. <code>cmp.Diff<\/code> on a <code>[]Invoice<\/code> is one line. Diffing two iterators means collecting both into slices first, which means you&rsquo;ve added ceremony for no gain.<\/p>\n<p>This maps to the broader question of when a Go feature pays for its own complexity. For code I expect to write once and read fifty times, plain loops still win. For code I expect to wrap into a library and hand to other teams, iterators are the better contract. I keep most of my reusable libraries in a <a href=\"https:\/\/abrarqasim.com\/work\" rel=\"noopener\">public portfolio repo<\/a> and the conversion pattern has been the same in each: anything that paginates becomes a <code>Seq2[T, error]<\/code>, anything that walks a tree becomes a <code>Seq[T]<\/code>, and the rest stays a slice.<\/p>\n<h2 id=\"what-id-do-this-week-if-i-were-starting-fresh\">What I&rsquo;d do this week if I were starting fresh<\/h2>\n<p>If you haven&rsquo;t touched iterators yet, three concrete things you can do today.<\/p>\n<p>Go find one place in your codebase where you have a <code>for { ... if last { break } }<\/code> pattern around an external API. Wrap it in an iterator. The diff will be smaller than you expect, the call site will get clearer, and you&rsquo;ll learn the yield-bool dance in a context where it doesn&rsquo;t matter much.<\/p>\n<p>Replace one <code>maps.Keys(m)<\/code> or <code>slices.SortedFunc<\/code> callsite with the new iterator-based equivalent. The standard library calls are drop-in for most uses and the allocation savings are real on hot paths.<\/p>\n<p>Resist writing your own <code>Map<\/code> and <code>Filter<\/code>. The Go team <a href=\"https:\/\/github.com\/golang\/go\/discussions\/61898\" rel=\"nofollow noopener\" target=\"_blank\">is still arguing about whether to ship them<\/a> for a reason: in a language without method chaining, the syntax is genuinely ugly, and a regular <code>for<\/code> body with an <code>if<\/code> is more readable. Wait for the standard library to land an opinion before you build your own.<\/p>\n<p>Iterators are not the most exciting change in Go this decade. They&rsquo;re a small, well-scoped tool that quietly fixes a category of caller-side bugs. After eight months I reach for them more than I expected to, and I write less wrapper boilerplate than I did before. That&rsquo;s enough.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>I&#8217;ve used Go&#8217;s range-over-func iterators in production for eight months. Here&#8217;s what I keep reaching for, what I dropped, and where the new pattern bites.<\/p>\n","protected":false},"author":2,"featured_media":187,"comment_status":"","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"","rank_math_description":"I've used Go's range-over-func iterators in production for eight months. Here's what I keep reaching for, what I dropped, and where the new pattern bites.","rank_math_focus_keyword":"golang iterators","rank_math_canonical_url":"","rank_math_robots":"","footnotes":""},"categories":[147,159],"tags":[49,215,216,213,47,214],"class_list":["post-188","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-backend","category-go","tag-backend","tag-go-1-23-2","tag-go-1-24","tag-go-iterators","tag-golang","tag-range-over-func"],"_links":{"self":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/188","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=188"}],"version-history":[{"count":0,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/188\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media\/187"}],"wp:attachment":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media?parent=188"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/categories?post=188"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/tags?post=188"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}