Skip to content

Iterators: what go vet and gofmt catch

Level: 201 · anyone who wants to know what the standard tools say about a loop over a function — and what they never will

One line: No vet analyzer is about iterators, but three of them meet iterator code: stdversion flags iter.Seq in a go 1.22 module that the compiler accepts, copylocks sees a lock copied through slices.Values as well as through a plain range, and unusedresult catches a dropped slices.Collect; -gcflags=-m shows the loop body as a function named main-range1 and says whether it inlined; everything that makes an iterator wrong — a forgotten check of yield, a forgotten stop, a second pass over a single-use iterator — compiles, vets clean, and is caught by nothing.

stdversion: iter.Seq requires go1.23 or later

A module whose go.mod says go 1.22 returns an iter.Seq[int] and calls slices.Collect, with no range over a function anywhere.

Verified output of iterators_vet_stdversion_sh.sh — regenerated by tools/run_examples.py, never hand-typed.

$ go mod init example
$ go mod edit -go=1.22
$ go build .
exit status 0
$ go vet .
# example
# [example]
./main.go:10:35: iter.Seq requires go1.23 or later (module is go1.22)
./main.go:21:21: slices.Collect requires go1.23 or later (module is go1.22)
exit status 1
$ go mod edit -go=1.23
$ go vet .
exit status 0

The warning means: the compiler accepted the program — importing a package is not a language feature, so go 1.22 does not stop it — but the module claims to build with Go 1.22, and iter.Seq and slices.Collect do not exist there. Anyone building this module with a 1.22 toolchain gets undefined: iter.Seq. The stdversion analyzer ↗ checks every standard-library symbol against the module's Go version.

The good version: go mod edit -go=1.23, after which the same go vet exits 0, as the transcript shows. If the module has to stay at 1.22, the iterator has to go.

When the tool is right, and when it is noise: right whenever the go line is a promise to others — a library. In a main module that nobody else builds, raising the line is the whole fix, and the finding is a reminder that the compiler would not have given (requires go1.23 or later, which only fires on the range statement itself).

copylocks: range var copies lock — through slices.Values too

A slice of Account values, each holding a sync.Mutex, ranged over by value: once with a plain range, once through slices.Values.

Verified output of iterators_vet_copylocks_sh.sh — regenerated by tools/run_examples.py, never hand-typed.

$ go vet copies.go
# command-line-arguments
# [command-line-arguments]
./copies.go:17:9: range var a copies lock: command-line-arguments.Account contains sync.Mutex
./copies.go:20:6: range var a copies lock: command-line-arguments.Account contains sync.Mutex
exit status 1
$ go vet indexes.go
exit status 0

The warning means: each iteration copies a whole Account, mutex included, into a; a lock held on the copy protects nothing (A mutex guards a counter). The interesting line is the second: slices.Values yields elements by value through yield, and vet's copylocks ↗ reports the range variable of a range over a function just as it does for a range over the slice.

The good version: indexes.go ranges over i — plain range accounts or slices.All, using only the index — and reaches accounts[i] in place; vet exits 0 on it. A slice of *Account is the other fix.

When the tool is right, and when it is noise: right every time the element holds a Mutex, WaitGroup or anything else with a Lock method; it is not noise. Note what it does not cover: an iterator of your own that yields a struct with a lock by value is reported at the range statement, but the copy inside your iterator, where yield(accounts[i]) makes it, is not.

unusedresult: result of slices.Collect call not used

slices.Sorted(parts) and slices.Collect(parts) called as statements.

Verified output of iterators_vet_unusedresult_sh.sh — regenerated by tools/run_examples.py, never hand-typed.

$ go vet dropped.go
# command-line-arguments
# [command-line-arguments]
./dropped.go:12:2: result of slices.Sorted call not used
./dropped.go:13:2: result of slices.Collect call not used
exit status 1
$ go vet kept.go
exit status 0

The warning means: both functions build a new slice and return it; they change nothing else. A call whose result is dropped did nothing but allocate, and the programmer probably thought Sorted sorted something in place. The unusedresult ↗ analyzer has a list of such pure functions, and go1.25's list includes these.

The good version: parts := slices.Sorted(maps.Keys(stock)) — kept.go, vet exit status 0.

When the tool is right, and when it is noise: right; there is no reason to call a pure function for nothing. What it does not know is your own iterator adapters — a Filter or Take called as a statement runs nothing (the loop never ranges over it) and vet says nothing, because those functions are not on its list.

go build -gcflags=-m: the loop body is main-range1, and it inlines

Not vet, but the right lens for what a range over a function costs. A tiny program sums three readings:

Verified output of iterators_vet_gcflags_m_sh.sh — regenerated by tools/run_examples.py, never hand-typed.

$ go build -gcflags=-m total.go
./total.go:5:6: can inline Readings
./total.go:15:2: can inline main-range1
./total.go:15:2: inlining call to Readings
./total.go:18:13: inlining call to fmt.Println
./total.go:15:2: inlining call to main-range1
./total.go:5:15: yield does not escape
./total.go:6:25: []int{...} does not escape
./total.go:15:2: []int{...} does not escape
./total.go:18:13: ... argument does not escape
./total.go:18:14: total escapes to heap
exit status 0
$ go build -gcflags="-m -l" total.go
./total.go:5:15: yield does not escape
./total.go:6:25: []int{...} does not escape
./total.go:15:2: func literal does not escape
./total.go:18:13: ... argument does not escape
./total.go:18:14: total escapes to heap
exit status 0

What it shows: the loop body was compiled to a function named main-range1 — the rewrite from Range over a function made visible — and with inlining on, the compiler inlined both Readings and main-range1 into main: the whole loop flattened back into straight-line code. With -l the body appears as a func literal does not escape, a closure that lives on the stack because Readings does not keep yield. The wiki ↗ puts it this way: for simple bodies and simple iterators below the inlining threshold the work involved is small, and for complex ones the call overhead is insignificant next to the body. The names -range1, -range2 … were chosen so that stack traces and this output tell a loop body from an ordinary closure (issue 66468 ↗). The compiler says what it inlines reads the same flag on other code.

gofmt

gofmt -s has no simplification for a range over a function; the _ = yield(a) && yield(b) one-liner in this chapter's examples stays as written. There is nothing here for gofmt beyond formatting.

What no tool catches

Every program in this list compiles, passes go vet, and is wrong:

  • An iterator that does not check what yield returned. yield(1); yield(2) with no if !yield compiles and vets clean; the runtime panics with range function continued iteration after function for loop body returned false — but only on a run in which the loop breaks early. break makes yield return false records the message.
  • iter.Pull without stop. next, _ := iter.Pull(seq) is a legal blank identifier, and vet's lostcancel (which catches an unused cancel from context.WithCancel) knows nothing about stop. The producer stays suspended in state [coroutine] for the life of the program — the fence in iter.Pull needs its stop.
  • A second range over a single-use iterator. lines := strings.Lines(text) ranged twice gives every line and then nothing; nothing in the type says single-use, only the doc comment. slices and maps have iterators prints the empty second pass.
  • An iterator that keeps yield for later, or recovers the body's panic. Both are refused at run time with their own messages (after whole loop exit, recovered a loop body panic and did not resume panicking), and neither is visible to the type checker or to vet. The same lesson's driver records both.
  • A Filter or Take called as a statement, which is a no-op nobody reports, because unusedresult only knows the standard library's functions.

staticcheck ↗ and golangci-lint ↗ are the tools that go further than vet; they are not run in this library, and at the time of writing neither has a check for the first three items either — an iterator's contract is in its documentation, not its type.

Sources