Last Updated: May 22, 2026
Before Go 1.23, the range keyword worked on arrays, slices, maps, strings, channels, and integers (since 1.22). Go 1.23 added one more form: ranging over a function. A function with the right signature can now drive a for loop, which means any data source, paginated APIs, filtered streams, infinite sequences, can be consumed with plain for x := range source {... }. This lesson covers the three accepted signatures, how the loop talks to the function through a yield callback, and how to compose iterators without copying everything into a slice first.
A shopping catalog rarely lives in a single slice. The data might come from a paginated API, a streaming database cursor, or a generator that produces filtered results on demand. Before Go 1.23, the choices were limited. You could return a giant slice and pay the memory cost up front, return a channel and pay the goroutine cost, or expose a Next() / Value() pair and force every caller to write the same boilerplate.
Range-over-func gives you a fourth option. You write one function that produces values lazily, and callers iterate it with the same for... range they use for any other collection. No goroutines. No slices built ahead of time. No bespoke iterator interfaces.
Here's a slice-based version of "list the first three products in stock" so we have something to compare against.
InStock allocates a new slice that holds every in-stock product, even though the caller only reads three. With ten products this doesn't matter. With ten million it does. The function-based version we'll build next stops producing values the moment the caller breaks out of the loop.
Returning a filtered slice runs the filter over the entire input even when the caller wants the first few results. A range-over-func iterator only does the work for items the caller actually consumes.
Go 1.23 only accepts three function shapes for ranging. The compiler rejects anything else. They differ only in how many values the iterator yields per step.
| Signature | Loop form | Useful for |
|---|---|---|
func(yield func() bool) | for range f {... } | Counting, side effects, "next step?" loops |
func(yield func(V) bool) | for v := range f {... } | A stream of single values |
func(yield func(K, V) bool) | for k, v := range f {... } | Key-value pairs (positions, IDs, map-like data) |
The yield parameter is a callback the iterator calls once per value it wants to emit. The loop body runs inside yield. When the loop body finishes one iteration, yield returns true and the iterator can emit the next value. When the caller breaks, returns, or otherwise exits the loop, yield returns false, and the iterator must stop.
Here's the simplest one in practice. Steps yields three checkout phases with no associated values, just a "tick" the caller can react to.
First, the iterator is just an ordinary function, no type to define, no interface to satisfy. Second, the loop uses for range CheckoutSteps without parentheses. That's how Go distinguishes "range over the function value" from "range over the result of calling the function".
The single-value form is what most iterators use. It looks like ranging over a slice from the caller's side.
Prices returns an iterator rather than being one directly. That's a common pattern: the outer function captures configuration (the catalog), and the returned closure is the actual iterator that the range consumes.
The two-value form yields a pair on every step. The pair often represents an index and a value, but it can also be a product ID and a stock count, or a row key and a row value.
The relationship between the iterator and the loop is the part that throws people off at first, because the control flow doesn't look like a normal function call. When the loop body needs to run, the iterator calls yield(value). When the body finishes one iteration, yield returns. Then the iterator decides whether to call yield again, with a new value, or stop. The boolean yield returns tells the iterator whether the loop wants more.
The diagram captures the loop as a back-and-forth between the iterator (which produces values) and the loop body (which consumes them). Every iteration is one yield(v) call, and the value yield returns tells the iterator whether the caller still wants more.
Two things follow from this design. First, break, return, panic, and labeled breaks inside the loop body all translate into yield returning false. The iterator doesn't see what happened, only that the answer was "stop". Second, the iterator is responsible for honoring that answer. If you call yield again after it returned false, the runtime panics with a specific message.
Three things happen on the iteration that prints 3. The iterator calls yield(3). The loop body checks n == 3 and runs break. Control returns to yield, which returns false. The iterator sees false, prints its diagnostic, and returns.
Forgetting that check is the most common bug in hand-written iterators. The fix is the same if !yield(v) { return } pattern in every loop.
The runtime catches the misuse and panics with a clear message. That's deliberate: ignoring break would make iterators non-composable, because outer iterators that wrap inner ones rely on the boolean to propagate stop signals correctly.
Calling yield after it returned false always panics at runtime. There's no way to recover gracefully inside the iterator. Always return on !yield(...).
The real value of range-over-func is that you can model any sequence as one of these functions. Three patterns come up often: paginated sources, infinite sequences, and recursive walks.
A paginated catalog reads pages on demand. The iterator hides the pagination logic from the caller, who only sees a flat stream of products.
The caller asked for three products. The iterator fetched two pages, the first to serve notebook and pen, the second to serve eraser, and never touched the third page. That's lazy evaluation working as intended.
Infinite sequences are equally natural. The iterator just keeps yielding values. The loop body decides when to stop.
OrderIDs would run forever if the caller didn't break. That's fine, because nothing actually runs until yield is called, and yield only returns when the loop body finished. The iterator and the loop alternate.
Recursive walks fit the same shape. Here's a category tree where each node has children, and we want to iterate all products in any depth.
The wrapper closure inside All is the key. When a recursive call's inner yield returns false, the wrapper records that, returns false from the inner iteration, and the outer All then returns. Propagating the stop signal correctly through every layer is the responsibility of the iterator author.
Recursive iterators allocate a small closure per nesting level. For shallow trees this is invisible. For deeply nested data, consider an explicit stack inside one flat iterator function.
Because iterators are just functions, you can write helpers that transform them. The three classic transformations are Filter (drop values that fail a predicate), Map (transform each value), and Take (stop after N values). Each one returns a new iterator.
Four iterators chained together, and only the two values the caller actually wanted ever made it through the pipeline. None of the intermediate stages materialized a slice. When Take returns after the second value, every outer iterator sees yield return false and shuts down cleanly.
There's a subtle bug in the Filter body to be aware of. The condition is keep(v) && !yield(v). Reading it left to right: if the predicate fails, we skip the value and continue the loop. If the predicate passes, we call yield(v). If yield returns false, the iterator returns. If we'd written if !keep(v) || !yield(v), the loop would also exit on a failing predicate, which would cause us to miss any later items that should pass.
| Operation | What it does | Lazy? |
|---|---|---|
From(slice) | Adapt a slice into a Seq | Yes |
Filter(in, pred) | Yield only values that match pred | Yes |
Map(in, fn) | Yield fn(v) for each v | Yes |
Take(in, n) | Yield at most n values, then stop | Yes |
Every one of these is lazy: nothing runs until the caller actually starts ranging over the resulting iterator. That's what makes the pipeline efficient. If you want eager evaluation, the standard library's slices.Collect(in) (Go 1.23+) walks the iterator to completion and returns a slice.
Each composition layer adds one closure call per yielded value. For tight loops with millions of iterations and trivial predicates, that overhead is measurable. For typical request-handling workloads, it's noise.
Looking at the same problem solved four different ways makes the trade-offs concrete. To expose the first three in-stock products from a catalog. Here's the slice approach, the channel approach, the bespoke iterator interface, and finally range-over-func, side by side.
The visible output is identical. What changes is the cost. The slice version walks the whole catalog and allocates a result slice, even though the caller wanted two items. The channel version is lazy but leaks a goroutine because we abandoned the channel after two reads. The range-over-func version is lazy and clean: when the loop body breaks, yield returns false, InStockIter returns, and there's no background work left running.
| Approach | Lazy? | Allocates result? | Leak risk if abandoned? |
|---|---|---|---|
| Filtered slice | No | Yes (full slice) | None |
| Channel + goroutine | Yes | No | Yes (until context or close) |
Custom Next() interface | Yes | No | None, but verbose |
| Range-over-func | Yes | No | None |
That table is the elevator pitch for the feature. You get the laziness of channels without the goroutine, and the ergonomics of slices without the eager evaluation.
Range-over-func is a small feature with surprisingly sharp edges. A handful of rules cover most of them.
Always honor `yield`'s return value. The pattern is if !yield(v) { return }. Skip the check and the runtime will panic the moment the caller breaks out of the loop. The compiler can't catch this for you because it doesn't analyze whether your loop body checks the result.
Iterators are lazy, not concurrent. A range-over-func iterator runs on the same goroutine as the caller. It doesn't spawn anything. If you need concurrency, wrap the iterator's work in a goroutine yourself, or use a channel-based producer. Don't assume yields happen in parallel.
Don't share an iterator across goroutines. Two goroutines ranging over the same iterator function value will race on whatever internal state the iterator keeps in its closure. If you need parallel consumers, give each one its own iterator (call the factory function again) or fan out through a channel.
`yield` is single-use per call. Each call to yield(v) corresponds to exactly one iteration of the loop body. Don't try to "buffer up" values and yield them in a batch; call yield once per value, in order.
State in the iterator is per-call, not per-value. When you call a factory function like Filter(in, keep), the returned iterator holds onto whatever state the closure captured. If you range over it twice, you get two independent walks (each call to the function starts a fresh for loop inside). That's usually what you want, but be careful with iterators that mutate captured state.
Each iteration of for n := range iter calls the iterator function again, which creates a fresh n starting at 100. The two walks don't share state. If you wanted a counter that remembers its position across ranges, you'd need to put the state outside the function (a struct field, for example).
The other gotcha worth flagging is API design. A function that returns an iterator can be called twice and produce two independent walks. A function that is an iterator (signature func(yield func(V) bool)) can also be ranged over multiple times, because each for... range call invokes it afresh. Either way, document whether your iterator is one-shot (drains a cursor that can't restart) or repeatable (always produces the same sequence). Callers will assume repeatable unless you say otherwise.
If your iterator wraps a non-repeatable resource (a paginated cursor, an unread network stream), make that visible in the function name or docs. Surprising callers with "second range yields nothing" is a debugging nightmare.