{"id":516,"date":"2026-07-28T13:05:14","date_gmt":"2026-07-28T13:05:14","guid":{"rendered":"https:\/\/abrarqasim.com\/blog\/go-iterators-2026-the-range-over-func-pattern-i-actually-use\/"},"modified":"2026-07-28T13:05:14","modified_gmt":"2026-07-28T13:05:14","slug":"go-iterators-2026-the-range-over-func-pattern-i-actually-use","status":"publish","type":"post","link":"https:\/\/abrarqasim.com\/blog\/go-iterators-2026-the-range-over-func-pattern-i-actually-use\/","title":{"rendered":"Go Iterators in 2026: The range-over-func Pattern I Actually Use"},"content":{"rendered":"<p>Short version for the impatient: if you&rsquo;re on Go 1.23 or newer, you can hand a plain function to <code>for range<\/code> and stop building slices you throw away two lines later. If you want to know why that&rsquo;s worth doing, stick around.<\/p>\n<p>I spent a genuinely dumb afternoon last week. I was writing a helper that walks a paginated API, and I did the thing I&rsquo;ve done a hundred times: allocate a slice, append every record to it, return the slice, let the caller loop over it once, then watch the garbage collector sweep up a few thousand structs nobody needed after the first pass. It works. It&rsquo;s also wasteful, and I only noticed because a profiler flagged the allocation. Range-over-func has been in the language since Go 1.23, and I still hadn&rsquo;t moved half my code to it. So I fixed that. Here&rsquo;s what actually clicked for me.<\/p>\n<h2 id=\"the-slice-i-kept-allocating-for-no-reason\">The slice I kept allocating for no reason<\/h2>\n<p>Here&rsquo;s the old shape. You&rsquo;ve written this. I&rsquo;ve written it more times than I&rsquo;d like to admit.<\/p>\n<pre><code class=\"language-go\">func FetchAll(client *Client) ([]Record, error) {\n    var out []Record\n    page := 0\n    for {\n        recs, hasMore, err := client.Page(page)\n        if err != nil {\n            return nil, err\n        }\n        out = append(out, recs...)\n        if !hasMore {\n            break\n        }\n        page++\n    }\n    return out, nil\n}\n\n\/\/ caller\nrecs, err := FetchAll(client)\nif err != nil {\n    return err\n}\nfor _, r := range recs {\n    process(r)\n}\n<\/code><\/pre>\n<p>The entire result set sits in memory even though the caller touches one record at a time. If the API hands back fifty thousand rows, you&rsquo;re holding fifty thousand rows. And if <code>process<\/code> bails on the third one, you paid to fetch and buffer the other 49,997 for nothing. For years the workaround was to accept a callback, which reads fine until you want to <code>break<\/code>, or <code>continue<\/code>, or return an error from inside the loop. Then you&rsquo;re inventing sentinel errors and it gets ugly fast.<\/p>\n<h2 id=\"what-range-over-func-actually-is\">What range-over-func actually is<\/h2>\n<p>The idea is small once it lands. Instead of returning data, you return a function that produces data, and <code>for range<\/code> knows how to drive it. The standard library added an <code>iter<\/code> package with two types for this, documented in the <a href=\"https:\/\/pkg.go.dev\/iter\" rel=\"nofollow noopener\" target=\"_blank\">iter package reference<\/a>:<\/p>\n<pre><code class=\"language-go\">type Seq[V any]     func(yield func(V) bool)\ntype Seq2[K, V any] func(yield func(K, V) bool)\n<\/code><\/pre>\n<p>That&rsquo;s the whole vocabulary. A <code>Seq[V]<\/code> is a function that takes a <code>yield<\/code> callback. You call <code>yield(v)<\/code> once per value. When <code>yield<\/code> returns <code>false<\/code>, the consumer is done and you stop. The Go team walks through the design reasoning in their post on <a href=\"https:\/\/go.dev\/blog\/range-functions\" rel=\"nofollow noopener\" target=\"_blank\">range over function types<\/a>, and it&rsquo;s worth reading once, because the <code>yield<\/code> return value is the part people skip and then get wrong.<\/p>\n<p>The mental model that finally made it stick for me: you&rsquo;re not returning values, you&rsquo;re returning the loop body&rsquo;s driver. Your iterator function is in charge of the &ldquo;for&rdquo; part, and <code>yield<\/code> is the caller&rsquo;s loop body. Every time you call <code>yield<\/code>, you&rsquo;re running one iteration of the caller&rsquo;s loop. The boolean it hands back is the caller saying &ldquo;keep going&rdquo; or &ldquo;I&rsquo;m out.&rdquo; That inversion feels strange for about ten minutes and then it&rsquo;s second nature. It&rsquo;s the same push model channels give you, except there&rsquo;s no goroutine, no channel allocation, and no risk of leaking a producer that&rsquo;s blocked forever on a send nobody&rsquo;s reading.<\/p>\n<h2 id=\"rewriting-a-real-iterator\">Rewriting a real iterator<\/h2>\n<p>Here&rsquo;s the paginated fetch again, this time as an iterator. Notice the caller barely changes, but nothing gets buffered.<\/p>\n<pre><code class=\"language-go\">import &quot;iter&quot;\n\nfunc AllRecords(client *Client) iter.Seq2[Record, error] {\n    return func(yield func(Record, error) bool) {\n        page := 0\n        for {\n            recs, hasMore, err := client.Page(page)\n            if err != nil {\n                yield(Record{}, err)\n                return\n            }\n            for _, r := range recs {\n                if !yield(r, nil) {\n                    return\n                }\n            }\n            if !hasMore {\n                return\n            }\n            page++\n        }\n    }\n}\n\n\/\/ caller\nfor r, err := range AllRecords(client) {\n    if err != nil {\n        return err\n    }\n    process(r)\n}\n<\/code><\/pre>\n<p>One record is alive at a time. If <code>process<\/code> decides to <code>break<\/code>, the <code>range<\/code> loop stops calling <code>yield<\/code>, <code>yield<\/code> returns <code>false<\/code>, and the iterator returns instead of fetching the next page. The error handling reads like normal Go now, no sentinel values, no callback that can&rsquo;t signal &ldquo;stop.&rdquo; I moved three helpers over to this shape and deleted more code than I added, which is usually a sign I was doing something silly before.<\/p>\n<p>The part that surprised me was testing. I&rsquo;d assumed iterators would be annoying to test, but they&rsquo;re not, because a <code>for range<\/code> in the test is exactly how production code consumes them. You loop, you collect into a slice with <code>slices.Collect<\/code>, and you assert on that slice. There&rsquo;s no fake callback to wire up and no interface to mock. If anything, the tests got shorter, because the iterator and its consumer speak the same built-in language.<\/p>\n<h2 id=\"seq2-early-exit-and-the-yield-contract\">Seq2, early exit, and the yield contract<\/h2>\n<p>The one rule that trips people up: after <code>yield<\/code> returns <code>false<\/code>, you must not call it again. If you do, the runtime panics, and honestly that panic saved me once by catching a loop where I ignored the return value. So the pattern is always <code>if !yield(x) { return }<\/code>, not a bare <code>yield(x)<\/code>. Get that habit early.<\/p>\n<p><code>Seq2<\/code> is the two-value version, and it&rsquo;s not only for key\/value maps. The <code>(Record, error)<\/code> pairing above is the idiom I reach for most, because it lets an iterator report a failure mid-stream without a separate channel or a stashed error field. If you only need values and never errors, use plain <code>Seq[V]<\/code> and keep it simple.<\/p>\n<p>Composition is the part I didn&rsquo;t expect to enjoy. Because an iterator is just a function, you can wrap one in another. A <code>Filter<\/code> that drops records failing a predicate is about six lines, and it streams, so filtering a huge source still holds one item at a time.<\/p>\n<pre><code class=\"language-go\">func Filter[V any](src iter.Seq[V], keep func(V) bool) iter.Seq[V] {\n    return func(yield func(V) bool) {\n        for v := range src {\n            if keep(v) &amp;&amp; !yield(v) {\n                return\n            }\n        }\n    }\n}\n<\/code><\/pre>\n<p>This is the same instinct I wrote about in my post on <a href=\"https:\/\/abrarqasim.com\/blog\/go-generics-2026-where-i-actually-use-type-parameters\" rel=\"noopener\">where I actually use Go generics<\/a>: the type parameter earns its place when it lets you write one honest helper instead of five copy-pasted ones.<\/p>\n<h2 id=\"when-i-dont-reach-for-an-iterator\">When I don&rsquo;t reach for an iterator<\/h2>\n<p>I&rsquo;m not going to pretend this belongs everywhere. If the data already fits comfortably in a slice and the caller wants random access or a length, hand back the slice. An iterator gives you neither <code>len()<\/code> nor indexing, and forcing callers to collect it back into a slice just to count it is worse than returning the slice in the first place. The Go 1.23 release notes cover the standard library helpers that landed alongside this, including <code>slices.Collect<\/code> and <code>maps.Keys<\/code>, and they&rsquo;re documented in the <a href=\"https:\/\/go.dev\/doc\/go1.23\" rel=\"nofollow noopener\" target=\"_blank\">Go 1.23 release notes<\/a>. Reach for iterators when the source is large, lazy, streaming, or expensive to produce. Keep slices for small, eager, already-in-memory data. That&rsquo;s the whole heuristic.<\/p>\n<p>The other honest caveat: there&rsquo;s a tiny per-call overhead to the <code>yield<\/code> indirection. For a hot loop over an in-memory slice of ints, a plain <code>for i := range s<\/code> is faster and you should just use it. Benchmark before you convert something on the hot path. I converted a parser inner loop once, felt clever, then measured and quietly reverted it.<\/p>\n<h2 id=\"what-to-try-this-week\">What to try this week<\/h2>\n<p>Find one function in your codebase that returns a slice the caller ranges over exactly once and never indexes. That&rsquo;s your candidate. Convert it to an <code>iter.Seq<\/code> or <code>iter.Seq2<\/code>, run your tests, and check the allocation count with <code>go test -bench . -benchmem<\/code>. If the allocations drop and the tests stay green, you&rsquo;ve found the pattern. If you build data pipelines like this a lot, that&rsquo;s the kind of plumbing I end up doing in most of my <a href=\"https:\/\/abrarqasim.com\/work\" rel=\"noopener\">backend work<\/a>, and iterators have quietly become the default shape for the streaming parts.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>Go 1.23 range-over-func lets you skip slices you throw away. Here is how I rewrote real iterators with iter.Seq, when it helps, and when a slice still wins.<\/p>\n","protected":false},"author":2,"featured_media":515,"comment_status":"","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"","rank_math_description":"Go 1.23 range-over-func lets you skip slices you throw away. Here is how I rewrote real iterators with iter.Seq, when it helps, and when a slice still wins.","rank_math_focus_keyword":"golang iterators","rank_math_canonical_url":"","rank_math_robots":"","footnotes":""},"categories":[159,45],"tags":[49,439,586,47,441,440],"class_list":["post-516","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-go","category-programming","tag-backend","tag-go-1-23-3","tag-go-iterators-2","tag-golang","tag-iter-package","tag-range-over-func-2"],"_links":{"self":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/516","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=516"}],"version-history":[{"count":0,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/516\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media\/515"}],"wp:attachment":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media?parent=516"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/categories?post=516"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/tags?post=516"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}