Deep Engineering
Advanced·Published·40 MIN

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 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 -rangeN suffix.
  • break is return false, continue is return true. The iterator does not know those words; all control passes through one boolean.
  • A return from the body does not leave immediately. First yield returns false, then the iterator finishes and runs its defer, and only then does the calling function return. This is what makes iterators worth writing: a defer in the iterator runs on every exit from the body, including a return and 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.

The Go Programming Language Specification, For statements with range clause

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

GO
for x := range f {
	...
}

into

GO
f(func(x T) bool {
	...
})

— and corrects itself on the very next line: But it's not usually that easy.

language contractcmd/compile/internal/rangefunc/rewrite.go, go1.24.7

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
measured observationbench/goiter/mechanics.go, go1.24.7 linux/amd64. Not a single timing here: what is checked is the order of the printed lines. The Russian original of this run is quoted verbatim in the Russian edition; the labels are translated, the order is not.

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
measured observationbench/goiter/mechanics.go, go1.24.7 linux/amd64. Verbatim from the run: these frame names contain no translatable text. Read from the bottom up.

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 didwhat the iterator saw, steps 0, 1, 2
ran to the endtrue, true, true
continuetrue, true, true
breakfalse on step 0, and no further steps
measured observationbench/goiter/mechanics.go, go1.24.7 linux/amd64. Three identical iterators and three different bodies; the printing is done by the iterator, not by the body. In the run output the first two groups of lines are identical character for character.

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.
language contractcmd/compile/internal/rangefunc/rewrite.go, go1.24.7

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.

Package iter

So what. If you write an iterator, you have no right to ignore the result of yield. The line

GO
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:

GO
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"
measured observationbench/goiter/mechanics.go, go1.24.7 linux/amd64. The iterator opens a resource and closes it in a defer; the body returns on the third value. The order of the lines is the whole result, and it is the only thing that carries meaning here.

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.

The Go Programming Language Specification, For statements with range clause

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:

GO
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:

GO
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
measured observationbench/goiter/mechanics.go, go1.24.7 linux/amd64, block 4 of the run. The recorded run is in Russian; the lines are translated here and the wording is the only thing that changed. Same iterator as in the previous block, plus a print after yield; the body panics instead of returning. What carries meaning is which lines are present, not how many.

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
measured observationbench/goiter/mechanics.go, go1.24.7 linux/amd64. Four deliberately wrong iterators; the texts are printed exactly as recover returned them. Only the four labels on the left are translated. The texts live in runtime/panic.go and do not depend on the machine.

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.
language contractcmd/compile/internal/rangefunc/rewrite.go, go1.24.7

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:

GO
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 yield and return on false. 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 around yield, 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
measured observationbench/goiter/mechanics.go, go1.24.7 linux/amd64. Both iterators print their return from a defer, so the output shows not only that they stopped but that their cleanup ran.

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:

GO
type Seq[V any]     func(yield func(V) bool)
type Seq2[K, V any] func(yield func(K, V) bool)
language contractPackage iter: the declarations are the whole of what the language gained. Everything else is ordinary library code.

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.

Package iter

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
measured observationbench/goiter/mechanics.go, go1.24.7 linux/amd64. The map keys in the run are Russian words, transliterated here; sorting them is deliberate, because map iteration order in Go is unspecified and the output would otherwise change from run to run. The Russian edition quotes this block verbatim.

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.

howns/opB/opallocs/opratio
plain range over a slice4782–4971001.0
range over slices.Values4108–4242000.9
range over a hand-written iter.Seq4024–4339000.9
iter.Seq as a function parameter4030–4327000.9
a callback, no iterator at all4027–4248000.9
measured observationbench/goiter/cost_test.go, go1.24.7 linux/amd64, Intel Xeon 2.80 GHz. A slice of 10,000 int, the body sums the values, seven interleaved rounds of one second each; the table shows the range across rounds. Every row of the block does the same work — TestSameWork checks that. Rows of different blocks are not compared with each other. Not one allocation, and the same time as a plain range: the compiler inlined both the iterator and the body, and no per-element call was left.

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.

howns/opB/opallocs/opratio
plain range over a slice4716–4931001.0
iter.Seq into a non-inlinable function28820–298104236.0
iter.Seq out of a package variable29615–319494236.2
measured observationbench/goiter/cost_test.go, go1.24.7 linux/amd64, Intel Xeon 2.80 GHz. The same slice and the same body as in the previous block. Inlining is prevented by a //go:noinline mark on the receiving function and by the iterator living in a package variable. The absolute nanoseconds depend on the machine; the ratio between rows of one block does not.

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.Seq arriving 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.

howns/opB/opallocs/opratio
plain range over a slice with the index4764–5186001.0
range over slices.All (an iter.Seq2)4752–4950001.0
measured observationbench/goiter/cost_test.go, go1.24.7 linux/amd64, Intel Xeon 2.80 GHz. The work here differs from the two previous blocks — the body multiplies the index by the value — so these rows are not compared with them, only with each other.

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.

howns/opB/opallocs/opratio
plain range, measured with b.Loop6209–6769001.0
slices.Values, measured with b.Loop29275–331328844.7
measured observationbench/goiter/cost_test.go, go1.24.7 linux/amd64, Intel Xeon 2.80 GHz. The same two rows as in the first cost block; only the way of measuring differs — b.Loop instead of a loop over b.N. They are compared with each other, not with that block.

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
language contractcmd/compile/internal/inline/interleaved/interleaved.go, go1.24.7

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 yield and return on false. Ignoring it is not allowed — it compiles, then crashes on the first break in somebody else's loop.
  • Put cleanup in the iterator's defer. It runs on every exit from the body, including a return from 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 by return.
  • 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 All and return an iter.Seq or an iter.Seq2 from it — then your type slots into the same slices.Collect and slices.Sorted as 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

Common misconceptions

Claim

for x := range f is syntactic sugar: the compiler just unrolls it into an ordinary loop

Actually

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.onemain.block1-range2main.othermain.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.

Claim

A return in the loop body leaves the function right away, as in an ordinary for

Actually

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.

Claim

The deferred exit is an awkward quirk to keep in mind

Actually

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.

Claim

An iterator that ignores the result of yield is merely sloppy code

Actually

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 }.

Claim

defer func() { recover() }() in an iterator is reasonable protection against its own mistakes

Actually

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.

Claim

A labelled break out of a nested loop over functions is a jump outwards, and the inner iterator's cleanup is lost

Actually

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.

Claim

An iterator costs a function call per element — that is the price of the mechanism

Actually

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.

Claim

A benchmark showed the iterator is six times more expensive than a plain loop, so it is

Actually

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.

Claim

iter.Seq is a new language entity, something like an iterator interface

Actually

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

Question 1 of 5

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

  1. 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
  2. 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
  3. 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
  4. 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
  5. 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
  6. 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