A Go API that returns a slice is pleasantly simple, but a slice isn’t always the right contract. Sometimes the caller only needs the first matching value. Sometimes producing each value requires work. Sometimes the complete result could be large enough that building it up front is wasteful.
Go 1.23 gives those APIs a standard alternative: iter.Seq. It represents a sequence that produces values on demand and works directly with a for range loop. The useful part isn’t just new syntax. An iter.Seq can hide a container’s representation, avoid an intermediate result slice, and stop producing values as soon as the caller stops iterating.
What iter.Seq actually represents
The definition is deliberately small:
type Seq[V any] func(yield func(V) bool)An iter.Seq[V] is a function. When called, it sends each value to yield. A true result from yield means “keep going”; false means the consumer is finished and the iterator must stop.
Go’s range-over-function support turns that callback protocol into an ordinary loop:
for value := range seq {
fmt.Println(value)
}That loop syntax matters because callers don’t need to know how the sequence is produced. It could walk a tree, scan an in-memory index, traverse a linked structure, or adapt another iterator. The API exposes iteration rather than storage.
Go 1.23 also defines iter.Seq2[K, V] for sequences that yield two values. It’s a natural fit for key-value or index-value iteration. This article focuses on Seq because its stop behavior and API trade-offs are easier to see with one value.
Build an iter.Seq that stops correctly
A small slice adapter shows the core pattern:
package main
import (
"fmt"
"iter"
)
func Values[T any](items []T) iter.Seq[T] {
return func(yield func(T) bool) {
for _, item := range items {
if !yield(item) {
return
}
}
}
}
func main() {
for name := range Values([]string{"Ada", "Linus", "Ken"}) {
fmt.Println(name)
}
}The if !yield(item) { return } check is the part worth remembering. It’s tempting to write yield(item) and ignore its result, especially when the first test consumes the entire sequence. That implementation is wrong for callers that use break.
For example:
for name := range Values(names) {
if strings.HasPrefix(name, "K") {
fmt.Println(name)
break
}
}When the loop breaks, the iterator’s current yield call reports false. The iterator should return immediately. Continuing to call yield after that isn’t merely wasted work; the iter contract says calling yield again after it has returned false is invalid and will panic.
Early termination is also where lazy iteration becomes practically different from returning a precomputed slice. If finding each item is expensive, a caller that stops after one item only pays for the values examined before the break.
Keep filtering lazy instead of building temporary slices
Iterator functions compose well when each layer preserves the same stop signal. A generic filter can take one sequence and return another:
func Filter[T any](seq iter.Seq[T], keep func(T) bool) iter.Seq[T] {
return func(yield func(T) bool) {
for value := range seq {
if keep(value) && !yield(value) {
return
}
}
}
}Now filtering doesn’t require allocating a second slice:
numbers := Values([]int{1, 2, 3, 4, 5, 6})
even := Filter(numbers, func(n int) bool {
return n%2 == 0
})
for n := range even {
fmt.Println(n)
}The pipeline stays lazy. Values produces one number, Filter decides whether to forward it, and the consumer handles it before the next number is requested.
There’s a subtle detail here: Filter ranges over its input sequence rather than calling the sequence manually. If its own yield returns false, Filter returns, which in turn breaks its range loop. Go propagates that stop back to the upstream iterator. Each layer only needs to honor its immediate consumer.
This style is useful when a sequence passes through several cheap transformations. It becomes less attractive when callers repeatedly need random access, the total count, or multiple passes over a materialized result. In those cases, a slice may express the job better.
Use iter.Seq to expose a data structure without exposing its storage
One strong use case for iter.Seq is a collection type whose internal representation shouldn’t become part of its public API.
Suppose a set is backed by a map:
type Set[T comparable] struct {
values map[T]struct{}
}
func (s *Set[T]) All() iter.Seq[T] {
return func(yield func(T) bool) {
for value := range s.values {
if !yield(value) {
return
}
}
}
}Callers get normal range syntax:
for value := range set.All() {
fmt.Println(value)
}The method doesn’t need to allocate and fill a slice just to make iteration possible. More importantly, returning an iterator doesn’t expose the backing map, so the implementation can change later without changing the iteration API.
The map caveat still applies: iteration order is unspecified. Wrapping a map in iter.Seq doesn’t make its order stable. If an API promises sorted output, it must do the sorting itself, which may require materializing values before yielding them.
Mutation semantics also need a deliberate contract. An iterator that directly traverses mutable internal state may observe changes made during iteration, depending on the underlying structure. For concurrent mutation, synchronization is still the collection’s responsibility. iter.Seq provides an iteration protocol, not thread safety or snapshot isolation.
Prefer a sequence when laziness is part of the API
Returning iter.Seq[T] instead of []T changes what callers can reasonably expect.
A slice is already materialized. Callers can take its length, index into it, sort it, retain it, and iterate over it repeatedly. Those properties are useful and familiar. If the result set is small and cheap to construct, replacing a slice with an iterator may only make the API harder to use.
A sequence makes more sense when at least one of these properties matters:
- producing every value has meaningful CPU, I/O, or allocation cost;
- callers commonly stop before consuming all values;
- the underlying collection shouldn’t be exposed or copied into a temporary slice;
- the sequence is naturally generated one item at a time;
- several lazy operations can be composed before materialization.
The key word is naturally. Don’t turn every slice-returning helper into an iterator because the language supports it. A configuration method returning five known strings is usually clearer as []string. A traversal that may visit hundreds of thousands of nodes and is often searched until the first match is a much stronger iterator candidate.
Materialize only at the boundary that needs a slice
Lazy APIs don’t prevent callers from getting a slice. The standard slices package includes Collect, which consumes an iter.Seq:
values := slices.Collect(Filter(
Values([]int{1, 2, 3, 4, 5, 6}),
func(n int) bool { return n%2 == 0 },
))
fmt.Println(values) // [2 4 6]This is a useful boundary: keep a pipeline lazy while values are being selected or transformed, then collect once when another API specifically needs a slice.
Go’s slices and maps packages also provide iterator-aware helpers. For example, slices.Values produces a sequence of slice values, while maps.Keys and maps.Values expose map contents as sequences. Before writing a small adapter yourself, check whether the standard library already has the operation.
Materialization still has its normal cost. slices.Collect must consume the sequence and store its values. If the sequence is extremely large or conceptually unbounded, collecting it defeats the reason for keeping it lazy and may exhaust memory.
Be careful with single-use sequences and captured state
The iter.Seq type doesn’t promise that a sequence can be iterated more than once. Whether it can depends on the function you return.
The earlier Values example is naturally reusable because each call starts a fresh for loop over the same slice. A sequence that closes over a mutable cursor may be single-use:
func Counter(limit int) iter.Seq[int] {
current := 0
return func(yield func(int) bool) {
for current < limit {
current++
if !yield(current) {
return
}
}
}
}The first range advances current. A second range continues from wherever the first one stopped, or produces nothing if the sequence was exhausted. That may be intentional, but it’s easy to surprise a caller who assumes Counter(3) describes a reusable sequence.
When possible, put iteration state inside the returned function so each invocation starts fresh:
func Counter(limit int) iter.Seq[int] {
return func(yield func(int) bool) {
for current := 1; current <= limit; current++ {
if !yield(current) {
return
}
}
}
}If a sequence truly represents a one-shot resource such as a streaming parser or cursor, document that constraint rather than hiding it behind an API that looks reusable.
Don’t use iter.Seq as an error channel
iter.Seq[T] has no return value for an error. That makes it a poor fit for operations where an error discovered during iteration must be reported naturally to the caller.
You can define a yielded result type containing a value and an error, or expose the error through another method, but both choices add protocol that callers must remember. In many cases a conventional API is clearer.
For example, a database query already has Rows.Next plus Rows.Err, and replacing that established pattern with a plain iter.Seq[Row] can accidentally hide a late scan or transport error. An iterator wrapper can work, but its error semantics need to be explicit.
The same caution applies to resource cleanup. A push-style iter.Seq is convenient because early break is propagated to the iterator, so deferred cleanup inside the iterator function still runs. If you convert a sequence to a pull iterator with iter.Pull, the API returns both next and stop; callers must call stop when they abandon iteration before next reports exhaustion.
Test both full consumption and early exit
Iterator tests should cover the path where the consumer stops. A sequence can look correct when fully consumed while mishandling yield(false).
A simple test can record how much source work occurred:
func TestSequenceStopsAfterBreak(t *testing.T) {
produced := 0
seq := func(yield func(int) bool) {
for i := 1; i <= 10; i++ {
produced++
if !yield(i) {
return
}
}
}
for value := range seq {
if value == 3 {
break
}
}
if produced != 3 {
t.Fatalf("produced %d values, want 3", produced)
}
}That test checks the behavior a lazy API is supposed to provide: stopping the consumer stops upstream work promptly. For reusable sequences, iterate twice in a test as well. For resource-backed sequences, test that early termination runs cleanup.
Choose iter.Seq for the contract, not the novelty
iter.Seq is most useful when it makes an API say something precise: values are produced as a sequence, callers may stop early, and they don’t need ownership of a materialized collection.
Start with the simplest representation that fits the caller. If a slice is small, cheap, and genuinely useful as a slice, return it. When construction is expensive, early exit matters, or exposing storage would be awkward, an iter.Seq gives Go code a standard lazy-iteration contract without inventing a custom Next interface.
For a first use, take an existing traversal that currently builds a temporary slice. Implement it as iter.Seq, make sure every yield result is honored, then test a caller that breaks after a few values. That exercise exposes the most important design question quickly: whether laziness actually improves the API rather than merely changing its shape.