Range over a function in Go: the loop body is a function the iterator calls, and everything else follows
`for x := range f` looks like sugar over an ordinary loop. It is not: the body becomes a separate function, `break` turns into `return false`, and a `return` from the body does not leave immediately — which is exactly why a `defer` inside the iterator always runs. Four iterator mistakes are caught by the runtime, and a traversal costs either nothing or a call per element, decided not by the iterator but by whether the compiler can see the function at the loop.
Full technical treatment
TL;DR
for x := range f, wherefis a function, arrived in Go 1.23. It is not a shorthand: the compiler makes a separate function out of the loop body and hands it to the iterator.- The iterator calls your body. Not the other way round.
breakisreturn false,continueisreturn true. The body has nothing else: one boolean for all control.- A
returnfrom the body does not leave immediately. The iterator finishes first — and itsdeferruns. So a file opened by the iterator is closed on every exit from the loop. - Four iterator mistakes are caught by the runtime, while running. One of
them is about a
defer recover()inside the iterator. - The cost: zero or sixfold. Decided not by the iterator but by whether the compiler can see the function at the loop.
What is actually going on
A function can now stand to the right of range. The specification explains
what that means in one sentence:
For a function f, the iteration proceeds by calling f with a new,
synthesized yield function as its argument.
So a range over a function is a call of that function. And the compiler
turns the loop body into a separate function and passes it in as the argument.
That argument is what gets called yield.
// what you wrote
for x := range f {
...
}
// what it becomes
f(func(x T) bool {
...
})No theory is needed to check this: let the iterator print what it is doing and the body print what it got.
for v := range talking { ... }
iterator: started
iterator: calling yield(0)
loop body: got 0
iterator: yield returned true
iterator: calling yield(1)
loop body: got 1
iterator: yield returned true
iterator: calling yield(2)
loop body: got 2
iterator: yield returned true
iterator: out of values, returning on my own
The lines alternate: the body ran between two lines of the iterator. There is only one way that happens — the body was called by the iterator itself.
All control passes through one value
The body became a function, and a function can only return a value. So everything the loop uses to control itself has to fit into the return value.
| body did | what the iterator saw, steps 0, 1, 2 |
|---|---|
| ran to the end | true, true, true |
continue | true, true, true |
break | false on step 0, and no further steps |
The first two rows are the same: to the iterator, continue and running to the
end are one event. It does not know the words break and continue — it sees
true or false.
What this means for you. If you write an iterator, the result of yield
cannot be ignored:
// broken: the traversal continues whatever the body answered
for _, v := range s {
yield(v)
}
// correct
for _, v := range s {
if !yield(v) {
return // the body said stop
}
}The first version compiles and looks harmless. It crashes on the first break
in somebody else's loop — with a panic whose text is below.
The main thing: a return from the body does not leave at once
The body is a function. You cannot return out of a function on behalf of
whoever called it. And a return inside the loop has to leave the outer
function, the one the loop is written in.
Calling a function whose loop body contains a return:
iterator: opened the resource
body: got 0
body: got 1
body: got 2
body: doing a return
iterator: yield returned false, returning
iterator: CLOSED the resource (defer)
the function returned: "a value from the loop body"
Look at the order. Nothing left the function after "doing a return". First
yield returned false. Then the iterator finished. Then its defer ran. Only
after that did the calling function return.
The compiler has nowhere to put that return: a live call of the iterator, with
defer statements not yet run, sits between the body and the outer function. So
a return in the body means "stop the loop and remember that we have to leave",
and the leaving happens later.
This is what makes iterators worth writing. Because the iterator always
gets to run its defer — on every exit from the body — cleanup inside it can be
written the way it is written in any other function:
func lines(name string) iter.Seq[string] {
return func(yield func(string) bool) {
f, err := os.Open(name)
if err != nil {
return
}
defer f.Close() // runs on every exit from the loop body
sc := bufio.NewScanner(f)
for sc.Scan() {
if !yield(sc.Text()) {
return
}
}
}
}The calling code does not need to know a file is open in there:
for line := range lines("access.log") {
if strings.Contains(line, "panic") {
return line // the file closes itself
}
}A callback cannot do this: you can neither leave the outer function from inside it nor stop the traversal — it has nothing to return. That is the difference, and it is not cosmetic.
There is a subtlety here, and it decides where cleanup goes. On a return from
the body the iterator gets control back and runs its own code after yield. On
a panic in the body it does not: yield never returns, the panic unwinds
straight through the iterator's frame, and of the iterator's own code only its
defer runs. Measured:
iterator: opened the resource
body: panicking
iterator: CLOSED the resource (defer)
caller: recovered the panic: body panic
Hence the rule: cleanup goes in a defer, not in a line after yield. And
put nothing but the exit after yield — on a return that line does run, and
anything more expensive than checking a boolean is paid twice.
Four mistakes the runtime catches
All four happen while running, not at compile time.
yield after break: runtime error: range function continued iteration after function for loop body returned false
yield after a panic: runtime error: range function continued iteration after loop body panic
yield after the loop: runtime error: range function continued iteration after whole loop exit
iterator ate the panic: runtime error: range function recovered a loop body panic and did not resume panicking
The first three are about calling yield again. Every loop carries a state
variable of its own, and every entry into the body is checked against it. That
is why an iterator which saved yield and calls it later, or from another
goroutine, fails predictably instead of corrupting data in silence.
The fourth is the one nobody arrives at on their own.
func bad(yield func(int) bool) {
defer func() { _ = recover() }() // looks like reasonable protection
yield(0)
}That defer is put there so the iterator will not bring the program down on a
mistake of its own. But a panic from the body passes through the iterator's
frame — the body was called from here — and this recover catches it too. From
the outside the panic would simply have vanished. The runtime does not allow
that.
Three rules follow: do not save yield; check its result and return on false;
do not put a blanket defer recover() in an iterator.
The cost: zero or sixfold
The usual argument against iterators is that a function call per element is expensive. There are two answers, and they have to be read together.
The compiler can see which function will arrive:
| how | ns/op | B/op | allocs/op | ratio |
|---|---|---|---|---|
plain range over a slice | 4782–4971 | 0 | 0 | 1.0 |
range over slices.Values | 4108–4242 | 0 | 0 | 0.9 |
range over a hand-written iter.Seq | 4024–4339 | 0 | 0 | 0.9 |
iter.Seq as a function parameter | 4030–4327 | 0 | 0 | 0.9 |
| a callback, no iterator at all | 4027–4248 | 0 | 0 | 0.9 |
It cannot:
| how | ns/op | B/op | allocs/op | ratio |
|---|---|---|---|---|
plain range over a slice | 4716–4931 | 0 | 0 | 1.0 |
iter.Seq into a non-inlinable function | 28820–29810 | 42 | 3 | 6.0 |
iter.Seq out of a package variable | 29615–31949 | 42 | 3 | 6.2 |
Same iterator, same work. One thing changed: in the second case the compiler does not know which function will arrive and cannot inline it. A call per element appeared, and so did allocations.
A rule you can apply by eye. An iterator is free where the compiler sees the
function at the loop: slices.Values(s) right in the loop header, your own
iterator nearby, a short wrapper. It costs a call per element where the function
arrives as a parameter of a large function, out of a variable, or out of a
struct field.
Not "do not use iterators" but "do not hide an iterator behind an inlining boundary in a hot loop".
Honestly, about that tenth. In the first table the iterator rows came out
slightly faster than the plain range, and the ranges did not overlap. It does
not follow that an iterator is faster: these are two equivalent loops the
compiler unrolled differently, and the sign of such a difference changes between
versions. What does follow is different — the expected sixfold is simply not
there.
Do not measure this with b.Loop
If you go and check the cost yourself, know about the trap.
| how | ns/op | B/op | allocs/op | ratio |
|---|---|---|---|---|
plain range, measured with b.Loop | 6209–6769 | 0 | 0 | 1.0 |
slices.Values, measured with b.Loop | 29275–33132 | 88 | 4 | 4.7 |
The same two rows as in the first table, and a different answer. The cause is
not the iterator but the measurement: since Go 1.24 the compiler does not inline
calls inside the body of b.Loop. It is written in the compiler itself:
No inlining nor devirtualization performed on b.Loop body
So a benchmark on b.Loop always shows only the expensive half — the one from
the second table. Measure with a loop over b.N, and with -benchmem.
iter.Seq is just a name for a function type
Nothing new was added to the language:
type Seq[V any] func(yield func(V) bool)
type Seq2[K, V any] func(yield func(K, V) bool)So your iterator is no different from a library one. maps.Keys and
maps.Values give a sequence over a map, slices.Values over a slice, and
slices.All gives index-value pairs. Going back: slices.Collect gathers into
a slice, slices.Sorted gathers and sorts. Hence the shortest way to walk a map
in order — slices.Sorted(maps.Keys(m)).
One convention is worth keeping: a collection's traversal method is called
All, and it returns an iter.Seq or an iter.Seq2. Then the calling code
reads the same everywhere: for k, v := range c.All().
What is out of scope here
iter.Pull — the opposite direction, where you pull the next value yourself;
that is a separate topic. And single-use iterators, the ones that cannot be
traversed twice: none of the measurements here involve one.
The short list
- Check the result of
yieldand return onfalse. - Put cleanup in the iterator's
defer— it runs on every exit from the body. - Put nothing but the exit after
yield. - Do not save
yieldand do not pass it to a goroutine. - Do not put a blanket
defer recover()in an iterator. - Call a collection's traversal method
All. - In a hot loop, do not hide the iterator behind an inlining boundary.
- Do not benchmark iterators with
b.Loop.
TL;DR
for x := range f is not a shorthand. The compiler turns the loop body into a
separate function and hands it to the iterator. Everything else follows.
- The iterator calls your body, not the other way round. The stack shows
alternating frames, and the body has a name of its own with a
-rangeNsuffix. breakisreturn false,continueisreturn true. The iterator does not know those words; all control passes through one boolean.- A
returnfrom the body does not leave immediately. Firstyieldreturnsfalse, then the iterator finishes and runs itsdefer, and only then does the calling function return. This is what makes iterators worth writing: adeferin the iterator runs on every exit from the body, including areturnand a panic. - Four iterator mistakes are caught by the runtime, all of them while
running. The least obvious one: a blanket
defer recover()in an iterator swallows your body's panic. - The cost is not one number but two, six times apart. Compiler sees the function — 4108–4242 ns and zero allocations; compiler does not — 28820–29810 ns, 42 B and 3 allocations per operation.
- Do not measure this with
b.Loop: inlining is disabled inside its body, so the iterator always comes out expensive.
A mechanism the language does not contain
Go 1.23 made it legal to write for x := range f, where f is a function. The
measurements in this article were taken on go1.24.7 — a toolchain on which the
mechanism has not been news for a year.
The temptation to read it as sugar is strong: to the right of range you could
already put a slice, a map, a channel and an integer, and now also a function —
one more case in the same list. Except that the compiler unrolls the other cases
into a loop, and turns this one inside out.
For a function f, the iteration proceeds by calling f with a new,
synthesized yield function as its argument.
Synthesized means invented by the compiler — and it invents it out of your loop
body. The comment in rewrite.go opens with exactly that:
The basic idea is to rewrite
for x := range f {
...
}into
f(func(x T) bool {
...
})— and corrects itself on the very next line:
But it's not usually that easy.
What follows is one place where it is not that easy per section. Each of them changes something about the code you will write.
The loop body is a function, and the iterator calls it
No theory is needed to check this: let the iterator print what it is doing and the body print what it got.
for v := range talking { ... }
iterator: started
iterator: calling yield(0)
loop body: got 0
iterator: yield returned true
iterator: calling yield(1)
loop body: got 1
iterator: yield returned true
iterator: calling yield(2)
loop body: got 2
iterator: yield returned true
iterator: out of values, returning on my own
The lines alternate: the body ran between two lines of the iterator. There is only one way that can happen — the body was called by the iterator itself.
The same run prints the call stack from the body of two nested loops over
functions, the outer one over one and the inner one over other:
0 main.frames
1 main.block1.one.block1-range2-range4
2 main.other
3 main.block1-range2
4 main.one
5 main.block1
6 main.main
7 runtime.main
8 runtime.goexit
Read bottom-up, the frames come in pairs: main.one is the iterator,
main.block1-range2 is the body of the outer loop, main.other is the second
iterator, main.block1.one.block1-range2-range4 is the body of the inner one.
The compound name on the top frame is a trace of inlining: the compiler pulled
one into block1 and built the name out of both.
So what. First, your loop body has a name and you will see it — in a panic
trace, in a profile, in a debugger. A frame with a -rangeN suffix is not
somebody else's code and not a runtime detail: it is the lines you wrote between
the braces, and that name is how you find them.
Second, and this matters more: there is now somebody else's frame between your
code before the loop and your code inside it. Everything that used to work
across the body boundary in an ordinary loop — leaving the function, defer, a
panic — now crosses a call boundary instead. The rest of the article is about
what follows from that.
break is return false, continue is return true
The body became a function, and a function can only return a value. So everything the loop uses to control itself has to fit into a return value — and there is exactly one.
| body did | what the iterator saw, steps 0, 1, 2 |
|---|---|
| ran to the end | true, true, true |
continue | true, true, true |
break | false on step 0, and no further steps |
The first two rows are identical. From the iterator's point of view continue
and running to the end are the same event, because both mean return true. The
compiler says so outright:
If the body contains a "break", that break turns into "return false", to tell f to stop. And if the body contains a "continue", that turns into "return true", to tell f to proceed with the next value.
The iter package states the same thing from the other side — the side of
whoever writes the iterator:
Yield returns true if the iterator should continue with the next element in
the sequence, false if it should stop.
So what. If you write an iterator, you have no right to ignore the result of
yield. The line
for _, v := range s {
yield(v) // result thrown away — the iterator is broken
}compiles and looks harmless, but it means "keep going whatever the body
answers". The first break in somebody's loop turns that into a panic — which
one exactly is two sections below. There is exactly one correct shape:
for _, v := range s {
if !yield(v) {
return // the body said stop, so the traversal must stop
}
}A return from the body does not leave immediately
Here the mechanism stops being a harmless rearrangement of code. The body is a
function; you cannot return out of a function on behalf of whoever called it.
And a return in a loop body has to leave the outer function, the one the
loop is written in.
Calling a function whose loop body contains a return:
iterator: opened the resource
body: got 0
body: got 1
body: got 2
body: doing a return
iterator: yield returned false, returning
iterator: CLOSED the resource (defer)
the function returned: "a value from the loop body"
Nothing left the function after the line "doing a return". First yield
returned false. Then the iterator ran its own code after yield. Then its
defer ran. And only after all of that did the calling function return — with
the value the body asked for three lines earlier.
It cannot be otherwise, and that is worth spelling out. The compiler has nowhere
to put that return: it cannot jump out of the body through the iterator's
frame, because between them sits a live call with defer statements not yet
run. So a return in the body means not "leave" but "stop the loop and remember
that we have to leave afterwards": the exit is stored in a variable, and the
exit itself is performed in the calling function, after the iterator has handed
control back.
The specification describes the same thing from the contract side:
If the loop body terminates (such as by a break statement), yield returns
false and must not be called again.
Note the word terminates: break is named as an example, not as the only case.
A return from the body is a termination too, and it reaches the iterator the
same way — as a false out of yield.
So what — and here the consequence is stronger than the fact. A deferred
exit means the iterator always gets to run its defer. Not usually, not if
the body behaved: always, on every exit from the body. And if that is so,
cleanup inside an iterator can be written the way it is written in any other
function:
func lines(name string) iter.Seq[string] {
return func(yield func(string) bool) {
f, err := os.Open(name)
if err != nil {
return
}
defer f.Close() // runs on every exit from the loop body
sc := bufio.NewScanner(f)
for sc.Scan() {
if !yield(sc.Text()) {
return
}
}
}
}The calling code neither closes the file nor knows it exists:
for line := range lines("access.log") {
if strings.Contains(line, "panic") {
return line // the file gets closed, though nobody asked for it
}
}This is why iterators are better than callbacks: a callback can neither stop the traversal nor leave the outer function — it has nothing to return — while an iterator allows both without handing ownership of the resource outwards. The mechanism is built this way for the sake of that property, and that property is exactly what gets lost when it is called sugar.
A panic reaches the iterator differently from a return
The word "always" above applies to defer — and to defer only. The
distinction is small in itself, but it decides where cleanup goes, so it was
measured separately. Same iterator, now with a line AFTER yield, and the body
panics instead of returning:
The same experiment as above, with two differences: the iterator
has a line AFTER yield, and the body panics instead of returning.
iterator: opened the resource
body: panicking
iterator: CLOSED the resource (defer)
caller: recovered the panic: body panic
There is no "line AFTER yield" in the output. On a panic yield does not return
control at all — the panic unwinds straight through the iterator's frame, and of
the iterator's own code only its defer runs.
Hence the rule this experiment was set up for: cleanup goes in a defer, not
in a line after yield. A line after yield will not run when the body
panics; a defer runs in both cases. And the other half of the same rule: put
nothing but the exit after yield — on a return from the body that line does
run, and anything there more expensive than checking a boolean is paid twice.
Four mistakes the runtime catches
The contract "yield returned false, do not call it again" has an enforcer.
All four violations are caught while running rather than at compile time, and
each has its own text.
yield after break: runtime error: range function continued iteration after function for loop body returned false
yield after a panic: runtime error: range function continued iteration after loop body panic
yield after the loop: runtime error: range function continued iteration after whole loop exit
iterator ate the panic: runtime error: range function recovered a loop body panic and did not resume panicking
Where they come from is explained in the same place that describes the rewrite:
To permit checking that an iterator is well-behaved -- that is, that it does not call the loop body again after it has returned false or after the entire loop has exited (it might retain a copy of the body function, or pass it to another goroutine) -- each generated loop has its own #stateK variable that is used to check for permitted call patterns to the yield function for a loop body.
So every loop carries a state variable of its own, and every entry into the body
is checked against it. That explains the first three texts and why they differ:
the state distinguishes "the body said stop", "the body panicked" and "the loop
is over entirely". A yield saved somewhere and called later, or from another
goroutine, fails predictably and with a readable text instead of corrupting data
in silence.
The fourth is the one nobody arrives at on their own. It is not about a repeated call at all:
func bad(yield func(int) bool) {
defer func() { _ = recover() }() // looks like reasonable protection
yield(0)
}That defer is written so the iterator will not bring the program down on a
mistake of its own. But a panic from the loop body passes through the
iterator's frame — the body was called from here — and this recover catches it
too. From the outside that would look like a panic that vanished: the loop ended
quietly, the calling function carried on, and a panic from its own code
surfaced nowhere. The runtime does not allow that and fails itself, with the
text range function recovered a loop body panic and did not resume panicking.
So what. Three rules, each straight out of those four lines.
- Do not save
yield— not in a field, not in a package variable, not in a goroutine. It lives for exactly one traversal. - Check the result of
yieldand return onfalse. That is not a style preference but the only shape that does not crash. - Do not put a blanket
defer recover()in an iterator. If you do need to catch your own panic, catch it around your own code rather than aroundyield, and always resume somebody else's.
Nesting and labels: not a jump but two stops in a row
A labelled break across two levels looks like a jump past the intermediate
code. But there is nowhere to jump: two iterator frames sit between the levels,
and both have to hand control back.
Two loops over functions, a labelled break out of the inner one:
body: A1
body: A2
body: A3
inner iterator: returned
body: B1
body: B2
body: break Loop
inner iterator: yield returned false
inner iterator: returned
outer iterator: yield returned false
outer iterator: returned
It reads like this: the inner yield returned false, the inner iterator
finished and left; the body of the outer loop returned false too, the outer
iterator finished and left. Two stops in a row, each through its own yield.
The normal path is visible in the same output: after A3 the inner iterator
returned on its own, with no false involved, and the next line is B1. The
inner iterator's cleanup runs on every step of the outer loop, not once at
the end.
So what. A labelled break out of a nested loop over functions is safe: the
defer of both iterators will run. But the cost is worth remembering too — if
the inner iterator opens a connection on every step of the outer loop, it closes
one on every step as well, and nothing in the loop's own code shows that.
iter.Seq is a name for a function type, not a new entity
Nothing was added to the language for any of the above. The iter package
contains two type declarations, and both are just names:
type Seq[V any] func(yield func(V) bool)
type Seq2[K, V any] func(yield func(K, V) bool)The package documentation defines an iterator in the words normally used for a callback:
An iterator is a function that passes successive elements of a sequence to a
callback function, conventionally named yield.
The standard library rests on that and asks for nothing more. maps.Keys and
maps.Values hand back an iter.Seq over a map, slices.Values over a slice,
and slices.All gives index-value pairs as an iter.Seq2. Going the other way,
slices.Collect gathers a sequence into a slice and slices.Sorted gathers and
sorts it. This is why slices.Sorted(maps.Keys(m)) is the shortest way to walk
a map in key order, and why it contains no new concept at all.
The run confirms all of it at once, including a hand-written collection:
slices.Sorted(maps.Keys(m)) -> [grusha sliva yabloko]
slices.Sorted(maps.Values(m)) -> [1 2 3]
for k, v := range (set{m}).All() // All() returns an iter.Seq2
grusha 3
sliva 2
yabloko 1
slices.Collect(slices.Values([]int{4,1,3})) -> [4 1 3]
for i, v := range slices.All([]string{"a", "b", "c"})
0 a
1 b
2 c
So what. Because iter.Seq is the name of a function type, your own
iterator is no different from a library one: it fits into slices.Collect, into
slices.Sorted, into any third-party function that takes an iter.Seq. Write
it as a function, not as an object with state.
And one convention worth holding to: a collection's traversal method is called
All. That is what the iter package and the standard library call it, and
it makes the calling code read the same everywhere:
for k, v := range c.All().
The cost is not one number but two
Now the thing that most often postpones the whole mechanism: "a function call per element has to be expensive". There are two answers, six times apart.
First, the case where the compiler knows at the loop which function will arrive.
| how | ns/op | B/op | allocs/op | ratio |
|---|---|---|---|---|
plain range over a slice | 4782–4971 | 0 | 0 | 1.0 |
range over slices.Values | 4108–4242 | 0 | 0 | 0.9 |
range over a hand-written iter.Seq | 4024–4339 | 0 | 0 | 0.9 |
iter.Seq as a function parameter | 4030–4327 | 0 | 0 | 0.9 |
| a callback, no iterator at all | 4027–4248 | 0 | 0 | 0.9 |
Now the same iterator and the same work, but the compiler cannot see the function: it arrives as a parameter of a function too large to inline, or it lives in a package variable.
| how | ns/op | B/op | allocs/op | ratio |
|---|---|---|---|---|
plain range over a slice | 4716–4931 | 0 | 0 | 1.0 |
iter.Seq into a non-inlinable function | 28820–29810 | 42 | 3 | 6.0 |
iter.Seq out of a package variable | 29615–31949 | 42 | 3 | 6.2 |
These two blocks have to be read together. On its own each one misleads: by
the first an iterator is always free, by the second always expensive. Neither is
true. The very same line for v := range seq costs either nothing or a call
per element, and what decides that is not the iterator but whether the
compiler knows the function at the loop.
Hence a rule you can apply by eye, without a profiler.
- Free where the compiler sees the function: a literal or a call such as
slices.Values(s)right in the loop header, your own iterator nearby, a short wrapper function that inlines whole. - A call per element plus allocations where it does not: an
iter.Seqarriving as a parameter of a large function, sitting in a variable, coming out of a struct field or an interface. The measurement covers two of those — a parameter of a non-inlinable function and a package variable; the rest are the same case for the same reason, that the compiler cannot name the function.
The practical conclusion is not "do not use iterators" but "do not hide an iterator behind a boundary the compiler will not cross, in a hot loop". In cold code, 25 microseconds per 10,000 elements is not worth a single rearrangement.
Key-value pairs need no separate discussion: iter.Seq2 behaves the same way.
| how | ns/op | B/op | allocs/op | ratio |
|---|---|---|---|---|
plain range over a slice with the index | 4764–5186 | 0 | 0 | 1.0 |
range over slices.All (an iter.Seq2) | 4752–4950 | 0 | 0 | 1.0 |
About the tenth the iterator came out faster by
In the first block the iterator rows sit slightly below the plain range:
4108–4242 against 4782–4971. The ranges did not overlap, and that has to be said
out loud rather than glossed over.
It does not follow that an iterator is faster than a plain range. These
are two different but equivalent loops: the same sum, unrolled differently by
the compiler on this machine. The sign of a difference like that changes between
versions and between machines, and nothing can be built on it.
What does follow from the block is substantive: the expected sixfold is not there, and no row allocates. The same time and the same zero bytes show up in the last row as well — a traversal by callback, with no iterator at all. In this case the iterator adds nothing: no time, no memory.
The benchmarking trap: on b.Loop an iterator is always expensive
This section exists because after the previous one the reader will go and measure it, and on current Go will write a benchmark that lies.
| how | ns/op | B/op | allocs/op | ratio |
|---|---|---|---|---|
plain range, measured with b.Loop | 6209–6769 | 0 | 0 | 1.0 |
slices.Values, measured with b.Loop | 29275–33132 | 88 | 4 | 4.7 |
b.Loop arrived as the recommended replacement for a loop over b.N — it keeps
the timer itself and keeps the body from being optimized away. But since Go 1.24
the compiler does not inline calls inside its body:
No inlining nor devirtualization performed on b.Loop body
For ordinary code this is a detail. For an iterator it decides everything,
because its whole cost consists of whether the compiler inlined the function or
not. Measuring on b.Loop disables inlining by force and therefore always
shows only the expensive half — the one from the second cost block.
So what. If you are measuring the cost of an iterator, measure it with a
loop over b.N, the way bench/goiter/cost_test.go does, and do not forget
-benchmem: the allocation column says more here than the nanoseconds. And a
benchmark of somebody else's showing that Go iterators are six times more
expensive is worth checking for a b.Loop in its body first.
What is out of scope here
Two things are named so that you know they exist and do not look for them above.
iter.Pull — the opposite direction: turning an iterator into a pair of
functions you pull yourself when you want the next value. It has its own
construction and its own cost; it is a separate topic.
Single-use iterators — the ones that cannot be traversed twice because a consumable source sits behind them. None of the measurements here involve one, and every conclusion above applies to iterators that can be called again.
What to check in your own code
- Writing an iterator: check the result of
yieldand return onfalse. Ignoring it is not allowed — it compiles, then crashes on the firstbreakin somebody else's loop. - Put cleanup in the iterator's
defer. It runs on every exit from the body, including areturnfrom the middle and a panic. That is the main reason to write an iterator rather than a callback. - Put nothing but the exit after
yield. Anything else there also runs when the body leaves byreturn. - Do not save
yield— not in a field, not in a package variable, not in a goroutine: the runtime catches that and brings the program down. - Do not put a blanket
defer recover()in an iterator: it swallows your body's panic, and the runtime reports that with a panic of its own. - Call a collection's traversal method
Alland return aniter.Seqor aniter.Seq2from it — then your type slots into the sameslices.Collectandslices.Sortedas everything else. - In a hot loop, do not hide the iterator behind an inlining boundary: a parameter of a large function, a package variable, a struct field. There it costs a call per element and three allocations.
- Do not benchmark iterators with
b.Loop. Inlining is off inside its body, and the answer will always be "expensive".
How to reproduce the numbers
Two scripts, answering different questions. bench/goiter/mechanics.go measures
no time at all: it prints the order of calls, the stack, the panic texts and the
output of the standard library. bench/goiter/cost_test.go together with
bench/goiter/cost.sh measures the cost.
The directory is a separate Go module, so the tests run from inside it:
go run bench/goiter/mechanics.go
cd bench/goiter && ./cost.sh
cd bench/goiter && go test .
The third command is TestSameWork: it checks that every way of traversing
produces the same sum. Without it the benchmark would be comparing different
work and saying nothing about it — a row that accidentally walked half the slice
would simply look fast.
cost.sh runs the benchmarks in interleaved rounds and prints the range across
rounds rather than the best result. If the ranges of two rows overlap, there is
no difference between them, however large it may look in a single run.
The published run: go1.24.7 linux/amd64, Intel Xeon 2.80 GHz. The mechanism
arrived in Go 1.23 and the numbers were taken on 1.24.7 — on another version the
absolute nanoseconds will differ. What will not change is the main thing: the
B/op and allocs/op columns, and the fact that the whole gap between the two
cost blocks rests on one circumstance, whether the compiler can see the
function. The output of mechanics.go contains no numbers at all, so it
reproduces verbatim.
What measured this
This is neither a retelling nor a separate text: everything below is taken from the article itself — its own summary, the section headings, the “actually” column and the version table. Which is why these theses cannot drift from the article.
The gist
for x := range fis not a shorthand. The compiler turns the loop body into a separate function and hands it to the iterator. Everything else follows.- The iterator calls your body, not the other way round. The stack shows alternating frames, and the body has a name of its own with a
-rangeNsuffix. breakisreturn false,continueisreturn true. The iterator does not know those words; all control passes through one boolean.- A
returnfrom the body does not leave immediately. Firstyieldreturnsfalse, then the iterator finishes and runs itsdefer, and only then does the calling function return. This is what makes iterators worth writing: adeferin the iterator runs on every exit from the body, including areturnand a panic. - Four iterator mistakes are caught by the runtime, all of them while running. The least obvious one: a blanket
defer recover()in an iterator swallows your body's panic. - The cost is not one number but two, six times apart. Compiler sees the function — 4108–4242 ns and zero allocations; compiler does not — 28820–29810 ns, 42 B and 3 allocations per operation.
- Do not measure this with
b.Loop: inlining is disabled inside its body, so the iterator always comes out expensive.
In fact
- It does not unroll it, it turns it inside out. The specification says so outright: For a function f, the iteration proceeds by calling f with a new, synthesized yield function as its argument. The loop body becomes a SEPARATE function that the iterator calls, and it has a name of its own: in the stack taken from the body of two nested loops the frames read
main.one→main.block1-range2→main.other→main.block1.one.block1-range2-range4— iterator, body, iterator, body. The-rangeNsuffix is invented by the compiler. From that single fact follow the deferredreturn, the four runtime panics, and the sixfold spread in cost. - Not right away. The order printed by the run: "body: doing a return", then "iterator: yield returned false, returning", then "iterator: CLOSED the resource (defer)", and only then the function returned its value. It cannot be otherwise: the body is a function, you cannot return out of it on behalf of the caller, and between the body and the outer function sits a live call of the iterator with
deferstatements not yet run. So areturnin the body means "stop the loop and remember that we have to leave": the exit is stored in a variable, and the exit itself happens after the iterator has handed control back. - It is precisely the property iterators are written for. Because the iterator always gets control back — on EVERY exit from the body, including a
returnfrom the middle and a panic — itsdeferalways runs. An iterator can open a file, hand out the lines and close the file, while the calling code neither knows about the file nor closes it. A callback cannot: you can neither leave the outer function from inside it nor stop the traversal, because it has nothing to return. - It is code that crashes. The first
breakin somebody else's loop producesruntime error: range function continued iteration after function for loop body returned false. There are four such checks and all of them happen while running, not at compile time: each generated loop has its own #stateK variable that is used to check for permitted call patterns to the yield function for a loop body. The only correct shape isif !yield(v) { return }. - This is the least obvious of the four errors. A panic from the BODY of the loop passes through the iterator's frame — the body was called from there — and a blanket
recovercatches it too. From the outside it would look like a panic that vanished. The runtime does not allow it:runtime error: range function recovered a loop body panic and did not resume panicking. Catching your own panic is fine, but do it around your own code rather than aroundyield, and resume somebody else's. - There is nowhere to jump: two iterator frames sit between the levels. The run shows two stops in a row: "inner iterator: yield returned false", "inner iterator: returned", then "outer iterator: yield returned false", "outer iterator: returned". Both got
false, both finished, and the cleanup of both ran. There is a cost here, but it is the opposite one: the inner iterator runs itsdeferon EVERY step of the outer loop. - The price is not one number but two, six times apart. When the compiler sees the function at the loop:
rangeoverslices.Valuestakes 4108–4242 ns at 0 B/op and 0 allocations, against 4782–4971 ns for a plainrangeover the slice. When it does not: 28820–29810 ns, 42 B/op and 3 allocations — the same iterator doing the same work. What decides it is not the mechanism but whether the compiler knows the function: a literal orslices.Values(s)in the loop header is free; a parameter of a large function, a package variable or a struct field costs a call per element. - First check whether the benchmark uses
b.Loop. Since Go 1.24 the compiler does not inline calls inside its body — No inlining nor devirtualization performed on b.Loop body — and an iterator there is always expensive. The same two rows that gave 4782–4971 and 4108–4242 ns when measured overb.Ngive 6209–6769 and 29275–33132 ns onb.Loop, at 88 B/op and 4 allocations. The whole difference is in the way of measuring, not in the iterator. - It is the name of a function type and nothing more:
type Seq[V any] func(yield func(V) bool). Not a single entity was added to the language for this mechanism, which is why your own iterator is no different from a library one and fits the sameslices.Collectandslices.Sorted. The package documentation defines it in the words normally used for a callback: An iterator is a function that passes successive elements of a sequence to a callback function, conventionally named yield.
What is covered
- A mechanism the language does not contain
- The loop body is a function, and the iterator calls it
- `break` is `return false`, `continue` is `return true`
- A `return` from the body does not leave immediately
- Four mistakes the runtime catches
- Nesting and labels: not a jump but two stops in a row
- `iter.Seq` is a name for a function type, not a new entity
- The cost is not one number but two
- The benchmarking trap: on `b.Loop` an iterator is always expensive
- What is out of scope here
- What to check in your own code
- How to reproduce the numbers
- What measured this
Common misconceptions
for x := range f is syntactic sugar: the compiler just unrolls it into an ordinary loop
It does not unroll it, it turns it inside out. The specification says so outright: For a function f, the iteration proceeds by calling f with a new, synthesized yield function as its argument
. The loop body becomes a SEPARATE function that the iterator calls, and it has a name of its own: in the stack taken from the body of two nested loops the frames read main.one → main.block1-range2 → main.other → main.block1.one.block1-range2-range4 — iterator, body, iterator, body. The -rangeN suffix is invented by the compiler. From that single fact follow the deferred return, the four runtime panics, and the sixfold spread in cost.
A return in the loop body leaves the function right away, as in an ordinary for
Not right away. The order printed by the run: "body: doing a return", then "iterator: yield returned false, returning", then "iterator: CLOSED the resource (defer)", and only then the function returned its value. It cannot be otherwise: the body is a function, you cannot return out of it on behalf of the caller, and between the body and the outer function sits a live call of the iterator with defer statements not yet run. So a return in the body means "stop the loop and remember that we have to leave": the exit is stored in a variable, and the exit itself happens after the iterator has handed control back.
The deferred exit is an awkward quirk to keep in mind
It is precisely the property iterators are written for. Because the iterator always gets control back — on EVERY exit from the body, including a return from the middle and a panic — its defer always runs. An iterator can open a file, hand out the lines and close the file, while the calling code neither knows about the file nor closes it. A callback cannot: you can neither leave the outer function from inside it nor stop the traversal, because it has nothing to return.
An iterator that ignores the result of yield is merely sloppy code
It is code that crashes. The first break in somebody else's loop produces runtime error: range function continued iteration after function for loop body returned false. There are four such checks and all of them happen while running, not at compile time: each generated loop has its own #stateK variable that is used to check for permitted call patterns to the yield function for a loop body
. The only correct shape is if !yield(v) { return }.
defer func() { recover() }() in an iterator is reasonable protection against its own mistakes
This is the least obvious of the four errors. A panic from the BODY of the loop passes through the iterator's frame — the body was called from there — and a blanket recover catches it too. From the outside it would look like a panic that vanished. The runtime does not allow it: runtime error: range function recovered a loop body panic and did not resume panicking. Catching your own panic is fine, but do it around your own code rather than around yield, and resume somebody else's.
A labelled break out of a nested loop over functions is a jump outwards, and the inner iterator's cleanup is lost
There is nowhere to jump: two iterator frames sit between the levels. The run shows two stops in a row: "inner iterator: yield returned false", "inner iterator: returned", then "outer iterator: yield returned false", "outer iterator: returned". Both got false, both finished, and the cleanup of both ran. There is a cost here, but it is the opposite one: the inner iterator runs its defer on EVERY step of the outer loop.
An iterator costs a function call per element — that is the price of the mechanism
The price is not one number but two, six times apart. When the compiler sees the function at the loop: range over slices.Values takes 4108–4242 ns at 0 B/op and 0 allocations, against 4782–4971 ns for a plain range over the slice. When it does not: 28820–29810 ns, 42 B/op and 3 allocations — the same iterator doing the same work. What decides it is not the mechanism but whether the compiler knows the function: a literal or slices.Values(s) in the loop header is free; a parameter of a large function, a package variable or a struct field costs a call per element.
A benchmark showed the iterator is six times more expensive than a plain loop, so it is
First check whether the benchmark uses b.Loop. Since Go 1.24 the compiler does not inline calls inside its body — No inlining nor devirtualization performed on b.Loop body
— and an iterator there is always expensive. The same two rows that gave 4782–4971 and 4108–4242 ns when measured over b.N give 6209–6769 and 29275–33132 ns on b.Loop, at 88 B/op and 4 allocations. The whole difference is in the way of measuring, not in the iterator.
iter.Seq is a new language entity, something like an iterator interface
It is the name of a function type and nothing more: type Seq[V any] func(yield func(V) bool). Not a single entity was added to the language for this mechanism, which is why your own iterator is no different from a library one and fits the same slices.Collect and slices.Sorted. The package documentation defines it in the words normally used for a callback: An iterator is a function that passes successive elements of a sequence to a callback function, conventionally named yield
.
Check yourself
A function contains a loop over an iterator, the iterator has a defer that closes a resource, and the loop body contains a return. Which happens first — leaving the function, or that defer?
Sources & further reading
6 SOURCES
- The Go Programming Language Specification — For statements with range clauseOfficial documentation. The sentence that says a range over a function is a call of that function: «For a function f, the iteration proceeds by calling f with a new, synthesized yield function as its argument». And the obligation whose violation the runtime catches: «If the loop body terminates (such as by a break statement), yield returns false and must not be called again». The word synthesized is the load-bearing one: the body function is invented by the compiler, and it has a name that shows up in the stack.https://go.dev/ref/spec#For_range
- Package iterOfficial documentation. The definition of an iterator, with no new language entity anywhere in it: «An iterator is a function that passes successive elements of a sequence to a callback function, conventionally named yield». And the contract of the single boolean through which all loop control passes: «Yield returns true if the iterator should continue with the next element in the sequence, false if it should stop». The naming convention comes from here too: a collection's traversal method is called All.https://pkg.go.dev/iter
- cmd/compile/internal/rangefunc/rewrite.go — how the compiler rewrites the loopSource. The comment at the top of the file opens with the simplest possible explanation — «The basic idea is to rewrite `for x := range f { ... }` into `f(func(x T) bool { ... })`» — and immediately corrects itself: «But it's not usually that easy». The same comment names both reasons why it is not. First, control words turning into a boolean: «If the body contains a "break", that break turns into "return false", to tell f to stop. And if the body contains a "continue", that turns into "return true", to tell f to proceed with the next value». Second, a state variable per loop: «each generated loop has its own #stateK variable that is used to check for permitted call patterns to the yield function for a loop body». The file has no address of its own — it lives in GOROOT; the link points at its copy in the Go repository at tag go1.24.7, the toolchain the measurements were taken on.https://github.com/golang/go/blob/go1.24.7/src/cmd/compile/internal/rangefunc/rewrite.go
- cmd/compile/internal/inline/interleaved/interleaved.go — why nothing is inlined inside b.LoopSource. The single line that makes any iterator benchmark written on `b.Loop` show only the expensive half of the truth: «No inlining nor devirtualization performed on b.Loop body». For ordinary code this is a detail; for an iterator it decides everything, because its whole cost consists of whether the compiler inlined the function or not. The file has no address of its own; the link points at the copy in the Go repository at tag go1.24.7.https://github.com/golang/go/blob/go1.24.7/src/cmd/compile/internal/inline/interleaved/interleaved.go
- runtime/panic.go — the texts of the iterator errorsGo source code. This is where all four texts live that the runtime prints when an iterator breaks the contract: it kept iterating after false, after a body panic, after the whole loop exited, and it swallowed a body panic without resuming it. These are run-time checks rather than compile-time ones, and the article quotes them verbatim from the run.https://go.dev/src/runtime/panic.go
- The Go Blog — Range Over Function TypesOfficial documentation. The official introduction to the mechanism that arrived in Go 1.23: why a common way to traverse somebody else's collection was needed, and why a function was chosen over an interface. This article leans on it for the problem statement only; every claim about behaviour and cost here comes from the specification, from compiler comments, and from its own runs.https://go.dev/blog/range-functions