{"id":406,"date":"2026-07-01T13:04:57","date_gmt":"2026-07-01T13:04:57","guid":{"rendered":"https:\/\/abrarqasim.com\/blog\/go-1-23-iterators-hand-rolled-boilerplate-i-stopped-writing\/"},"modified":"2026-07-01T13:04:57","modified_gmt":"2026-07-01T13:04:57","slug":"go-1-23-iterators-hand-rolled-boilerplate-i-stopped-writing","status":"publish","type":"post","link":"https:\/\/abrarqasim.com\/blog\/go-1-23-iterators-hand-rolled-boilerplate-i-stopped-writing\/","title":{"rendered":"Go 1.23 Iterators: The Hand-Rolled Boilerplate I Stopped Writing"},"content":{"rendered":"<p>I&rsquo;ve been writing Go since the 1.5 days, and for most of that time my answer to &ldquo;how do I iterate over this thing&rdquo; was the same tired dance: write a method that returns a slice, or hand-roll a <code>Next()<\/code>\/<code>Value()<\/code> pair, or pass a callback into a <code>Walk<\/code> function and live with the awkward early-exit problem. None of it was bad. All of it was a little annoying.<\/p>\n<p>Then Go 1.23 shipped <a href=\"https:\/\/go.dev\/blog\/range-functions\" rel=\"nofollow noopener\" target=\"_blank\">range-over-func and the <code>iter<\/code> package<\/a>, and I spent a confused weekend trying to figure out whether it was a real upgrade or just a new shape for the same problem. Short answer: it&rsquo;s a real upgrade, but only for a specific slice of code. I deleted maybe 400 lines across two services and added back about 60. That&rsquo;s the post.<\/p>\n<p>If you want the official spec, the <a href=\"https:\/\/go.dev\/doc\/go1.23\" rel=\"nofollow noopener\" target=\"_blank\">Go 1.23 release notes<\/a> cover the language change and the <a href=\"https:\/\/pkg.go.dev\/iter\" rel=\"nofollow noopener\" target=\"_blank\"><code>iter<\/code> package docs<\/a> cover the standard interface. This is the part of the story they don&rsquo;t tell you.<\/p>\n<h2 id=\"what-iterating-in-go-used-to-look-like\">What iterating in Go used to look like<\/h2>\n<p>Here&rsquo;s the shape I wrote a hundred times. Say I have a paginated API client and I want to walk every result without loading them all into memory:<\/p>\n<pre><code class=\"language-go\">\/\/ pre-1.23: callback-with-error\nfunc (c *Client) WalkOrders(ctx context.Context, fn func(Order) error) error {\n    cursor := &quot;&quot;\n    for {\n        page, err := c.fetchPage(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>The caller side wasn&rsquo;t terrible, but you couldn&rsquo;t just <code>break<\/code> out of it. You had to invent a sentinel error or thread a <code>bool<\/code> through. And if you wanted to compose two of these together (paginated orders for paginated customers, say), the type signatures got ugly fast.<\/p>\n<p>The other common shape was a <code>Next()<\/code>\/<code>Value()<\/code> cursor type, which works fine until you forget to call <code>Close()<\/code> and leak a database connection. I&rsquo;ve shipped that bug more than once. I&rsquo;m not proud of it.<\/p>\n<h2 id=\"what-range-over-func-actually-changed\">What range-over-func actually changed<\/h2>\n<p>The new pattern is that any function with the signature <code>func(yield func(V) bool)<\/code> is rangeable. The standard names live in <code>iter.Seq[V]<\/code> (one value) and <code>iter.Seq2[K, V]<\/code> (two values, like a map). Same paginated walker, rewritten:<\/p>\n<pre><code class=\"language-go\">\/\/ 1.23+: an iter.Seq2 that yields (order, error)\nfunc (c *Client) Orders(ctx context.Context) iter.Seq2[Order, error] {\n    return func(yield func(Order, error) bool) {\n        cursor := &quot;&quot;\n        for {\n            page, err := c.fetchPage(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>And the caller:<\/p>\n<pre><code class=\"language-go\">for order, err := range client.Orders(ctx) {\n    if err != nil {\n        return err\n    }\n    if order.Total &gt; threshold {\n        process(order)\n        break \/\/ and this actually works\n    }\n}\n<\/code><\/pre>\n<p>Three things matter here. <code>break<\/code> works because the <code>yield<\/code> call returns <code>false<\/code> and the producer cooperates by returning. Errors travel inline as a second value, which is a much better fit for Go&rsquo;s error-handling habits than the callback approach. And there&rsquo;s no <code>Close()<\/code> to forget, because the producer function exits when the loop exits.<\/p>\n<p>I&rsquo;ve written about <a href=\"https:\/\/abrarqasim.com\/blog\/go-generics-2026-type-parameters-i-actually-reach-for\/\" rel=\"noopener\">how I actually reach for Go generics<\/a>, and <code>iter.Seq<\/code> is one of the cleanest payoffs I&rsquo;ve found. Generics let you write <code>Map<\/code>, <code>Filter<\/code>, and <code>Take<\/code> once and have them work on any iterator. That&rsquo;s the thing that finally made me stop reaching for slices of intermediate values.<\/p>\n<h2 id=\"where-it-actually-saves-me-code\">Where it actually saves me code<\/h2>\n<p>Four places, ranked by how much I gained.<\/p>\n<p><strong>Streaming over a database cursor.<\/strong> I have a Postgres scan loop that used to look like a state machine. Now it&rsquo;s an <code>iter.Seq2[Row, error]<\/code> and the caller just ranges. <code>defer rows.Close()<\/code> lives inside the iterator, so nobody leaks anything.<\/p>\n<pre><code class=\"language-go\">func QueryRows[T any](ctx context.Context, db *sql.DB, sql string, scan func(*sql.Rows) (T, error), args ...any) iter.Seq2[T, error] {\n    return func(yield func(T, error) bool) {\n        rows, err := db.QueryContext(ctx, sql, args...)\n        if err != nil {\n            yield(*new(T), err)\n            return\n        }\n        defer rows.Close()\n        for rows.Next() {\n            v, err := scan(rows)\n            if !yield(v, err) {\n                return\n            }\n            if err != nil {\n                return\n            }\n        }\n        if err := rows.Err(); err != nil {\n            yield(*new(T), err)\n        }\n    }\n}\n<\/code><\/pre>\n<p><strong>Composing transformations.<\/strong> With a few helpers I rarely allocate intermediate slices anymore:<\/p>\n<pre><code class=\"language-go\">func Filter[T any](seq iter.Seq[T], pred func(T) bool) iter.Seq[T] {\n    return func(yield func(T) bool) {\n        for v := range seq {\n            if pred(v) &amp;&amp; !yield(v) {\n                return\n            }\n        }\n    }\n}\n\nfunc Take[T any](seq iter.Seq[T], n int) iter.Seq[T] {\n    return func(yield func(T) bool) {\n        i := 0\n        for v := range seq {\n            if i &gt;= n || !yield(v) {\n                return\n            }\n            i++\n        }\n    }\n}\n<\/code><\/pre>\n<p><strong>Tree and graph traversal.<\/strong> Yielding nodes from a recursive walk used to mean either a callback (with the early-exit problem) or building a giant slice. Now I yield from inside the recursion and the caller can stop whenever.<\/p>\n<p><strong>Pagination on third-party APIs.<\/strong> This is the biggest day-to-day win. Stripe, GitHub, our internal job queue, all wrapped as <code>iter.Seq2[T, error]<\/code>. The calling code reads like the API is local.<\/p>\n<h2 id=\"the-footguns-no-one-tells-you-about\">The footguns no one tells you about<\/h2>\n<p>Three gotchas I hit in the first month, none of which are obvious from the release notes.<\/p>\n<p><strong>The <code>yield<\/code> return value isn&rsquo;t optional.<\/strong> If you don&rsquo;t check it and return when it&rsquo;s <code>false<\/code>, your iterator keeps producing values into a loop that&rsquo;s already broken. In a pure CPU loop that&rsquo;s wasted work. In an HTTP-paginated iterator, that&rsquo;s real money. Always do <code>if !yield(v) { return }<\/code>.<\/p>\n<p><strong>Iterators are functions, not state machines.<\/strong> You can range over the same <code>iter.Seq<\/code> twice and the producer runs twice. That&rsquo;s almost always what you want, but if you&rsquo;re iterating a database cursor and the caller ranges twice by accident, you&rsquo;ll execute the query twice. I added a <code>Once<\/code> wrapper that panics on second iteration.<\/p>\n<p><strong><code>iter.Pull<\/code> exists when you really need state.<\/strong> Sometimes you want to peek the next value, or interleave two iterators, or otherwise turn <code>iter.Seq<\/code> into something that feels like the old <code>Next()<\/code> cursor. <code>iter.Pull<\/code> does exactly that and returns a <code>next, stop<\/code> pair. The catch: you have to call <code>stop<\/code>. The compiler will not remind you. I aliased it to <code>defer stop()<\/code> in every call site as a habit.<\/p>\n<p>There&rsquo;s a fourth one I&rsquo;ll mention briefly. Stack traces inside iterator functions are weirder than normal because the <code>yield<\/code> call is essentially a coroutine resume. If you&rsquo;re debugging, prefer adding logs at the yield boundary rather than relying on the stack you see at the panic site.<\/p>\n<h2 id=\"when-i-still-write-a-plain-for-loop\">When I still write a plain <code>for<\/code> loop<\/h2>\n<p>This is the bit I want to be honest about. Range-over-func is not always the answer.<\/p>\n<p>If the producer is in-memory and finite, a slice is still fine. <code>iter.Seq<\/code> over a 12-element config slice is performative; just use <code>range items<\/code>. The new pattern earns its keep when the producer is lazy, when iteration can fail, or when you want composability. For a <code>[]string<\/code> of usernames, write the loop.<\/p>\n<p>If the consumer needs random access or to iterate multiple times cheaply, materialize to a slice. <code>slices.Collect(seq)<\/code> is one line and gives you the array you wanted.<\/p>\n<p>If you&rsquo;re inside a hot path where allocation matters and you&rsquo;ve measured, the closure indirection in <code>iter.Seq<\/code> does have a real cost compared to an inlined loop. It&rsquo;s small, but it&rsquo;s not zero. Profile before you decide.<\/p>\n<h2 id=\"what-id-do-this-week-if-i-were-you\">What I&rsquo;d do this week if I were you<\/h2>\n<p>Pick one place in your codebase that currently uses a callback-style <code>Walk<\/code> or a <code>Next()<\/code>\/<code>Value()<\/code> cursor. Rewrite it as an <code>iter.Seq2[T, error]<\/code> and update the two or three call sites. Don&rsquo;t try to migrate everything at once. The win compounds: every callback you delete is one less custom signature your teammates have to remember, and <code>iter.Seq2[Foo, error]<\/code> becomes the shared vocabulary.<\/p>\n<p>If you want to see more of the kind of backend work I do with Go, Rust, and Postgres, my <a href=\"https:\/\/abrarqasim.com\/work\" rel=\"noopener\">project work is on abrarqasim.com<\/a>. And if you&rsquo;ve found a clever use of <code>iter.Pull<\/code> I haven&rsquo;t, I&rsquo;d actually like to hear about it. The use cases for it are narrower than I expected, and I&rsquo;m still figuring out where it earns its weight.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>Go 1.23&#8217;s range-over-func and the iter package killed a pile of iterator boilerplate I&#8217;d been writing for years. Here&#8217;s what actually changed for me.<\/p>\n","protected":false},"author":2,"featured_media":405,"comment_status":"","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"","rank_math_description":"Go 1.23's range-over-func and the iter package killed a pile of iterator boilerplate I'd been writing for years. Here's what actually changed for me.","rank_math_focus_keyword":"golang iterators","rank_math_canonical_url":"","rank_math_robots":"","footnotes":""},"categories":[147,159],"tags":[49,46,439,412,47,441,209,440],"class_list":["post-406","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-backend","category-go","tag-backend","tag-go","tag-go-1-23-3","tag-go-generics-2","tag-golang","tag-iter-package","tag-iterators","tag-range-over-func-2"],"_links":{"self":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/406","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=406"}],"version-history":[{"count":0,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/406\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media\/405"}],"wp:attachment":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media?parent=406"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/categories?post=406"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/tags?post=406"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}