Go Module 12 – Iterators and range-over-func
Pull iterators and lazy pipelines with generics
Pull values on demand with iter.Pull and always call stop, write generic Filter, Map and Take adapters, and choose iterators, channels or slices for a pipeline.
What you will learn
- Convert a push iterator to pull form with iter.Pull, and call stop on every path
- Write generic Filter, Map and Take adapters that stop their source as soon as the loop stops
- Walk two sequences in step with iter.Pull, to zip them or to merge two sorted ones
- Define a Stream type with methods, including a generic Map method of Go 1.27
- Choose between iterators, pulls, channels and slices for a pipeline, and avoid the leaks of channel generators
Before you start
What you’ll learn · Before you start
What you will learn
- Convert a push iterator to pull form with iter.Pull, and call stop on every path
- Write generic Filter, Map and Take adapters that stop their source as soon as the loop stops
- Walk two sequences in step with iter.Pull, to zip them or to merge two sorted ones
- Define a Stream type with methods, including a generic Map method of Go 1.27
- Choose between iterators, pulls, channels and slices for a pipeline, and avoid the leaks of channel generators
Before you start
On this page
Finding the PNRs that appear in two sorted lists of 10,000 needs both lists to move in step, but a range loop drives one iterator at a time, and the iterator decides when its next value comes. Pull iterators turn that around: the consumer asks for each value when it wants one.
You will pull values with iter.Pull and release them with stop, then build lazy pipelines with generics from small adapters such as Filter, Map and Take, and see how much work they skip. Each example was run on Go 1.27.1, and its printout is shown as recorded.
iter.Pull turns a push iterator into next and stop
iter.Pull(seq) returns two functions. Each call of next returns the sequence’s next value and true, or the zero
value and false once the sequence is over. stop ends the iteration early and releases what it holds. A
pull iterator: An iterator the consumer calls for each value: next returns the value and whether there was one. iter.Pull makes one from a push iterator, with a stop function that must run if the sequence is not read to its end. like this suits a consumer that is not a single loop: one that reads two
sequences in step, or takes one value at a time from code that is driven by something else. Pull iterators come in
two shapes, iter.Pull for an iter.Seq and iter.Pull2 for an iter.Seq2. This program pulls from Coaches twice, once to
the end and once with an early stop:
// pull.go: iter.Pull turns a push iterator into a next function and a stop function.
package main
import (
"fmt"
"iter"
)
// Coaches yields two coach codes and says when it returns.
func Coaches() iter.Seq[string] {
return func(yield func(string) bool) {
defer fmt.Println(" (Coaches returned)")
for _, c := range []string{"S4", "B1"} {
if !yield(c) {
return
}
}
}
}
func main() {
next, stop := iter.Pull(Coaches())
defer stop()
for range 3 {
c, ok := next()
fmt.Printf("next() = %q, %v\n", c, ok)
}
fmt.Println("a second pull, stopped after one value:")
next2, stop2 := iter.Pull(Coaches())
c, ok := next2()
fmt.Printf("next() = %q, %v\n", c, ok)
stop2()
c, ok = next2()
fmt.Printf("next() after stop = %q, %v\n", c, ok)
} Output
next() = "S4", true next() = "B1", true (Coaches returned) next() = "", false a second pull, stopped after one value: next() = "S4", true (Coaches returned) next() after stop = "", false
Recorded with Go 1.27.1 on macOS 26 arm64. To run it yourself: go run pull.go
The first pull gave S4 and B1, then Coaches returned, and the third next gave “” and false. The second pull read S4 and called stop: Coaches returned at once, during stop, and next then gave “” and false. Step through the first pull:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- iter.Pull returns two functions. iter.Pull(Coaches()) returns next and stop and runs nothing yet. main defers stop, so the iterator is released however main ends.
- The first next. main calls next. The iterator starts and runs until its first yield.
- The iterator yields S4. Coaches calls yield("S4"). Instead of running a loop body, yield hands S4 to next and the iterator waits inside yield.
- next returns S4, true. next returns "S4" and true to main, which prints it. The iterator stays paused until main asks again.
- The second next. The second call resumes Coaches where it waited. It yields B1, and next returns "B1" and true.
- The sequence ends. On the third call Coaches has no value left, so it returns and prints (Coaches returned). next returns the zero value "" and false.
- stop is still called. main's deferred stop runs when main returns. The sequence had ended, so there is nothing left to release, and calling stop is harmless.
The package states the rule: a consumer that does not read the sequence to its end must call stop. Writing
defer stop() on the line after iter.Pull covers every path, and calling stop after the end is harmless.
Checkpoint
Check: next and stop from iter.Pull
Adapters wrap one iterator in another, and do only the work asked of them
Filter, Map and Take each take a sequence and return a new one that ranges over it. With type parameters, one Filter serves every element type and Map can change the type, from seat numbers to text. adapters.go chains the three over a million seats and counts what the source had to make:
// adapters.go: generic Filter, Map and Take wrap one iterator in another, and do only the work the loop asks for.
package main
import (
"fmt"
"iter"
)
// Filter yields the values of seq for which keep returns true.
func Filter[V any](seq iter.Seq[V], keep func(V) bool) iter.Seq[V] {
return func(yield func(V) bool) {
for v := range seq {
if keep(v) && !yield(v) {
return
}
}
}
}
// Map yields f(v) for each value v of seq.
func Map[V, W any](seq iter.Seq[V], f func(V) W) iter.Seq[W] {
return func(yield func(W) bool) {
for v := range seq {
if !yield(f(v)) {
return
}
}
}
}
// Take yields the first n values of seq, then returns, which stops seq too. It counts with a variable of each walk's
// own, left, so the iterator it returns can be walked more than once.
func Take[V any](seq iter.Seq[V], n int) iter.Seq[V] {
return func(yield func(V) bool) {
left := n
if left <= 0 {
return
}
for v := range seq {
if !yield(v) {
return
}
if left--; left == 0 {
return
}
}
}
}
// Seats yields seat numbers 1 to n and counts how many it produced.
func Seats(n int, produced *int) iter.Seq[int] {
return func(yield func(int) bool) {
for seat := 1; seat <= n; seat++ {
*produced++
if !yield(seat) {
return
}
}
}
}
func main() {
produced := 0
window := func(seat int) bool { return seat%2 == 1 } // odd seats are by the window in this coach
label := func(seat int) string { return fmt.Sprintf("window seat %d", seat) }
for s := range Take(Map(Filter(Seats(1_000_000, &produced), window), label), 3) {
fmt.Println(s)
}
fmt.Println("Seats produced", produced, "of 1,000,000")
} Output
window seat 1 window seat 3 window seat 5 Seats produced 5 of 1,000,000
Recorded with Go 1.27.1 on macOS 26 arm64. To run it yourself: go run adapters.go
The loop printed three window seats, and Seats produced 5 of its 1,000,000. Lazy pipelines with generics like this one do only what the loop at the end asks for. Each adapter runs its source only while its own yield keeps returning true. Follow the five seats through the stages:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- The loop asks Take. The loop ranges over Take, which ranges over Map, which ranges over Filter, which ranges over Seats. Nothing is produced until Seats is asked for its first seat.
- Seat 1 goes all the way. Seats produces 1. Filter keeps it, Map turns it into "window seat 1", Take passes it on, and the loop prints it.
- Seat 2 stops at Filter. Seats produces 2. Filter drops it and asks Seats for the next seat; Map, Take and the loop never see it.
- Seats 3 and 4. Seat 3 reaches the loop as "window seat 3"; seat 4 is dropped by Filter, as seat 2 was.
- Seat 5 fills Take. Seat 5 reaches the loop as "window seat 5". That was Take's third value, so Take returns instead of asking for more.
- False runs back up. Take's return makes the yield that Map called return false, so Map returns; the same reaches Filter and then Seats. Seats stopped at 5 of 1,000,000 seats.
Counting down a captured parameter
In Take, writing if n--; n == 0 changes the parameter n, which every walk of the returned iterator shares, so the
second walk starts at 0 and yields nothing. Copy it inside the function, left := n, as adapters.go does.
The rest of this lesson builds on these two ideas. Zip pairs two sequences and Merge interleaves two sorted ones, both by pulling. A Stream type gives a pipeline methods, and Go 1.27 lets its Map method change the element type.
Iterators are one of three ways to pass values from one stage of a program to the next. A channel carries values from a goroutine that produces them to another that consumes them, so the two can run at the same time. The producing goroutine leaks when its consumer stops early and nothing tells it to stop. A slice holds every value at once, so it costs memory for all of them before the first is read. In exchange, a slice can be read as many times as the program needs.
Checkpoint
Check: Lazy generic adapters
Two sequences in step: Zip and Merge, and what a forgotten stop costs Pro: opens with a Pro, Premium or Ultimate pass
Walking two sequences in step takes at least one pull. This part writes Zip and Merge with pulls, shows both stops running when a loop breaks, and counts the goroutines that 1,000 pulls hold when nobody calls stop.
In this part:
- Zip and Merge, built on one pull or two
- What a pull left without stop costs, measured
2 runnable examples (Go) · about 5 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
A Stream type with Go 1.27 generic methods, and iterators against channels Pro: opens with a Pro, Premium or Ultimate pass
Pipelines read better from left to right, and channels look like the obvious way to stream values. This part gives a sequence type methods, including the generic Map that Go 1.27 allows, then measures what a channel generator costs and leaks against an iterator and a pull.
In this part:
- A Stream type with Filter, Take and a generic Map method
- A channel generator leaks when its consumer stops early, measured
- Choosing iterators, pulls, channels or slices for a pipeline
3 runnable examples (Go) · about 9 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Practise: three adapters
practice/adapters.go has three adapters to write, and practice/adapters_test.go tests them, including a second walk of one Skip and a source that counts what TakeWhile asked for:
Go (adapters.go) · practice/adapters.go
// Package practice holds three adapters for you to write. Each must stop as soon as its yield returns false.
package practice
import "iter"
// Skip yields the values of seq after the first n.
func Skip[V any](seq iter.Seq[V], n int) iter.Seq[V] {
return func(yield func(V) bool) {
// write it
}
}
// TakeWhile yields the values of seq while ok returns true, and stops at the first value for which it is false,
// without asking seq for any more.
func TakeWhile[V any](seq iter.Seq[V], ok func(V) bool) iter.Seq[V] {
return func(yield func(V) bool) {
// write it
}
}
// Enumerate yields each value of seq with its position, counting from 0.
func Enumerate[V any](seq iter.Seq[V]) iter.Seq2[int, V] {
return func(yield func(int, V) bool) {
// write it
}
} Go (adapters_test.go) · practice/adapters_test.go
package practice
import (
"fmt"
"iter"
"slices"
"testing"
)
// counted yields 1 to n and counts how many values it produced.
func counted(n int, produced *int) iter.Seq[int] {
return func(yield func(int) bool) {
for i := 1; i <= n; i++ {
*produced++
if !yield(i) {
return
}
}
}
}
func TestSkip(t *testing.T) {
got := slices.Collect(Skip(slices.Values([]int{545, 1450, 2050, 3420}), 2))
if fmt.Sprint(got) != "[2050 3420]" {
t.Errorf("Skip(…, 2) = %v, want [2050 3420]", got)
}
}
func TestSkipTwice(t *testing.T) {
later := Skip(slices.Values([]int{545, 1450, 2050, 3420}), 2)
first, second := slices.Collect(later), slices.Collect(later)
if fmt.Sprint(first, second) != "[2050 3420] [2050 3420]" {
t.Errorf("two walks of one Skip gave %v and %v, want [2050 3420] both times", first, second)
}
}
func TestTakeWhileStopsAsking(t *testing.T) {
produced := 0
got := slices.Collect(TakeWhile(counted(1_000, &produced), func(n int) bool { return n < 4 }))
if fmt.Sprint(got) != "[1 2 3]" || produced != 4 {
t.Errorf("TakeWhile(n < 4) = %v after %d values, want [1 2 3] after 4", got, produced)
}
}
func TestEnumerate(t *testing.T) {
var got []string
for i, name := range Enumerate(slices.Values([]string{"Asha", "Ravi", "Meena"})) {
got = append(got, fmt.Sprint(i, ":", name))
if i == 1 {
break // a correct Enumerate returns now; one that ignores false makes the loop panic
}
}
if fmt.Sprint(got) != "[0:Asha 1:Ravi]" {
t.Errorf("Enumerate with a break at 1 gave %v, want [0:Asha 1:Ravi]", got)
}
} The starter code compiles, and every test fails:
from session import run
# The starter code of practice/ compiles, and every test fails until the three adapters are written.
run(
"""
cd practice && go test .
""",
expect_failure=["cd practice && go test ."],
) Output
$ cd practice && go test .
--- FAIL: TestSkip (0.00s)
adapters_test.go:25: Skip(…, 2) = [], want [2050 3420]
--- FAIL: TestSkipTwice (0.00s)
adapters_test.go:33: two walks of one Skip gave [] and [], want [2050 3420] both times
--- FAIL: TestTakeWhileStopsAsking (0.00s)
adapters_test.go:41: TakeWhile(n < 4) = [] after 0 values, want [1 2 3] after 4
--- FAIL: TestEnumerate (0.00s)
adapters_test.go:54: Enumerate with a break at 1 gave [], want [0:Asha 1:Ravi]
FAIL
FAIL example.com/practice 2.982s
FAIL
[exit status 1]
This output changes from run to run: go test prints how long each test and the whole run took, which differs from one run to the next.
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 practice_session.py
Make go test . pass in the practice folder. A first hint: Skip needs a counter of each walk’s own, skip := n inside
the function it returns, or TestSkipTwice catches the shared parameter.
The questions below cover pulls and adapters, and each one is explained as soon as you answer it.
Practice that adapts to you
Practise this lesson
8 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up (2 to 5 years): What do the two functions that iter.Pull returns do?
next returns the sequence’s next value and true, or the zero value and false when the sequence is over. stop ends the
iteration early: it makes the iterator return, running its deferred calls, and releases what the pull holds. A
consumer that does not read to the end must call stop, so write defer stop() right after iter.Pull.
Three longer questions of the module bank build on this lesson:
-
Mid to senior
A service compares two result sets per request by zipping them with iter.Pull. After a release, its goroutine count climbs all day and memory follows, though traffic is flat. What is the likely cause, how do you prove it, and how do you fix and guard it?
-
Mid to staff
An older package exposes func Bookings(day Date) <-chan Booking, backed by a goroutine that reads a database, and callers often stop after the first match. Explain what goes wrong, redesign the API with iterators, and say when you would keep a channel.
-
Mid to senior
Write Merge(a, b iter.Seq[int]) iter.Seq[int] that merges two sorted sequences lazily into one sorted sequence. Walk through its edge cases, what it costs, and how you would test that it stops both inputs when the caller breaks.
Key takeaways
- iter.Pull turns a push iterator into next and stop; a consumer that stops early must call stop, and
defer stop()right after the call covers every path. - Generic adapters such as Filter, Map and Take wrap one sequence in another, and a pipeline of them does only the work its last loop asks for: Seats made 5 of 1,000,000 values for Take(3).
- An adapter that counts down a captured parameter works once; a counter of each walk’s own makes the iterator reusable.
Terms in this lesson
- pull iterator
- An iterator the consumer calls for each value: next returns the value and whether there was one. iter.Pull makes one from a push iterator, with a stop function that must run if the sequence is not read to its end.
References
- Package iter: Pull (The Go Authors)
- Range Over Function Types (The Go Blog) (The Go Authors)
- The Go Programming Language Specification: Method declarations (The Go Authors)
- Go 1.27 Release Notes: Changes to the language (The Go Authors)
- Package runtime: NumGoroutine (The Go Authors)
- Package testing: Benchmark (The Go Authors)
- Go Concurrency Patterns: Pipelines and cancellation (The Go Blog) (The Go Authors)
Report a problem with this lesson
Kept only in this browser. Your Learn progress