The problem with temporary slices
You have a map of configuration headers that needs to be logged in alphabetical order. You have a slice of user IDs that needs to be filtered, deduplicated, and passed to a downstream service. The traditional Go approach requires building a temporary slice, sorting it, looping over it, and discarding it when the scope ends. You end up writing boilerplate just to move data from one shape to another. The allocations add up. The index tracking gets noisy. The code reads like a chore instead of a pipeline.
Go 1.23 introduced iterators to the standard library. The slices and maps packages now expose lazy sequences that integrate directly with range. You stop managing indices. You stop allocating intermediate collections. You describe what you want, and the runtime delivers it one value at a time.
What an iterator actually is
An iterator in Go is a function that yields values on demand. It does not build a new slice or map in memory. It computes the next value only when the consumer asks for it. The type signature looks like this: iter.Seq[T] func(yield func(T) bool) bool. The outer function returns a boolean indicating whether the sequence is complete. The inner yield function is called by the iterator to hand a value to the consumer. If the consumer returns false, the iterator stops early. If it returns true, the iterator continues.
Think of it like a water tap instead of a bucket. A traditional loop fills a bucket first, then drinks from it. An iterator turns on the tap, drinks a glass, turns it off, and repeats. The water never sits idle in memory. The pattern scales cleanly to maps with iter.Seq2[K, V], which yields a key and a value together.
Iterators are lazy. They compute on demand, not upfront.
The minimal pattern
Here is the simplest way to iterate over a map in sorted order and a slice in sorted order using the standard library.
package main
import (
"fmt"
"maps"
"slices"
)
func main() {
// maps.Keys returns an iterator over the map keys
// slices.Sorted consumes it and yields keys in ascending order
for k := range slices.Sorted(maps.Keys(config)) {
fmt.Println(k, config[k])
}
// slices.Values turns the slice into an iterator
// slices.Sorted consumes it and yields values in ascending order
for v := range slices.Sorted(slices.Values(numbers)) {
fmt.Println(v)
}
}
var config = map[string]string{
"host": "localhost",
"port": "8080",
"env": "dev",
}
var numbers = []int{42, 7, 19, 3}
The range keyword now accepts iter.Seq[T] and iter.Seq2[K, V] directly. You do not need to convert the iterator to a slice first. The loop drives the sequence automatically.
How the runtime drives the sequence
When the compiler sees for v := range slices.Sorted(slices.Values(numbers)), it generates a loop that calls the outer iterator function once. That function returns a yield callback and a done boolean. The runtime stores the callback and enters a loop. On each iteration, it calls yield with the next value. The iterator's internal state advances. When the iterator has no more values, it sets done to true and returns. The loop exits.
No intermediate slice is allocated. The memory footprint stays flat regardless of input size. The slices.Sorted function collects the values into a temporary slice only when it needs to sort them, because sorting requires random access. Functions like slices.Values, maps.Keys, and maps.Values are purely streaming. They never allocate.
The loop drives the iterator. The iterator never decides when to stop.
Chaining in a real pipeline
Real code rarely iterates over a single collection. You filter, transform, deduplicate, and pass the result downstream. Iterators compose cleanly because each function returns another iterator. You chain them like pipes.
Here is a realistic example that processes a slice of transaction records. It filters out incomplete transactions, extracts the amounts, sorts them, and passes the sequence to a reporting function.
package main
import (
"fmt"
"slices"
)
// ReportTotals consumes an iterator of amounts and prints the sum
func ReportTotals(amounts iter.Seq[int]) {
sum := 0
for a := range amounts {
sum += a
}
fmt.Println("total:", sum)
}
func main() {
// raw transactions from a database or API
transactions := []struct {
ID int
Amount int
Complete bool
}{
{1, 100, true},
{2, 50, false},
{3, 200, true},
{4, 75, true},
}
// slices.Values turns the struct slice into an iterator
// slices.Filter keeps only completed transactions
// slices.Map extracts the Amount field
// slices.Sorted orders the amounts for deterministic reporting
ReportTotals(
slices.Sorted(
slices.Map(
slices.Filter(
slices.Values(transactions),
func(t struct{ ID, Amount int; Complete bool }) bool {
return t.Complete
},
),
func(t struct{ ID, Amount int; Complete bool }) int {
return t.Amount
},
),
),
)
}
The chain reads left to right in terms of data flow, even though the code nests right to left. Each step is lazy. slices.Filter does not build a new slice. It hands values to slices.Map one at a time. slices.Map transforms them and hands them to slices.Sorted. slices.Sorted buffers only what it needs to sort, then yields. The reporting function consumes the stream without ever seeing the original struct slice.
Chain iterators like pipes. Let the data flow, not the allocations.
Where iterators trip you up
Iterators change the mental model of loops. A few patterns cause friction until you adjust.
The first trap is expecting eager evaluation. If you assign an iterator to a variable but never range over it, nothing happens. The sequence is a description, not a result. You must drive it with a loop or iter.Pull.
The second trap is passing the wrong type to a function. Functions that accept iterators expect iter.Seq[T], not []T. If you pass a slice directly, the compiler rejects the program with cannot use mySlice (variable of type []string) as iter.Seq[string] value in argument. Wrap it with slices.Values(mySlice) to convert it.
The third trap is ignoring the yield return value. The yield function returns bool. true means continue. false means the consumer wants to stop early. If your custom iterator ignores this flag, it will keep running after the loop breaks, wasting CPU cycles. Always check the return value and return immediately when it is false.
Lazy means nothing happens until you range. Call the sequence or lose the data.
Convention asides
The Go community treats iterators as plumbing, not magic. You will see gofmt handle the new syntax without complaint. The formatter aligns nested iterator chains cleanly, so you do not need to argue about indentation. Most editors run gofmt on save, and the output stays consistent across teams.
When you write a custom iterator, name the yield parameter yield and the outer function yieldSeq or seq. Keep the signature close to iter.Seq[T]. If you need to discard a value from a Seq2, use _ for the unused variable. for k, _ := range maps.All(m) signals that you intentionally dropped the value. The underscore is a deliberate choice, not an accident.
Public iterator helpers start with a capital letter. Private ones start lowercase. The standard library follows this strictly. slices.Values is public. Internal helpers in your package stay lowercase. Accept interfaces, return structs. Iterators are functions, so they fit the pattern naturally.
When to reach for iterators
Iterators solve a specific class of problems. They are not a replacement for every loop. Pick the right tool based on the shape of your data and the constraints of your code.
Use a traditional for i, v := range slice loop when you need direct index access or simple iteration over a small collection.
Use slices.Values or maps.Keys when you want to pass a collection to a function that expects an iterator.
Use slices.Filter, slices.Map, or maps.All when you need to chain transformations without allocating intermediate slices.
Use slices.Sorted or maps.SortedKeys when you need deterministic ordering without mutating the original data.
Use iter.Pull when you need to manually drive the sequence, such as in a custom parser or when integrating with C APIs that require explicit next calls.
Stick to plain loops when performance profiling shows iterator overhead matters in a tight inner loop.
Loops are the foundation. Iterators are the plumbing.