An iterator is convenient while data is flowing through a pipeline, but sooner or later you may need those values in a slice. If you already have a destination slice, collecting the iterator separately and then appending it creates an unnecessary intermediate step.
Go 1.23 added slices.AppendSeq for exactly this boundary. It consumes an iter.Seq, appends each yielded value to an existing slice, and returns the resulting slice.
What slices.AppendSeq does
The function has a compact signature:
func AppendSeq[Slice ~[]E, E any](s Slice, seq iter.Seq[E]) SliceThe first argument is the slice to extend. The second is an iterator producing elements of the same type. AppendSeq ranges over that sequence and appends the yielded values in order.
Here’s a complete example:
package main
import (
"fmt"
"iter"
"slices"
)
func evens(limit int) iter.Seq[int] {
return func(yield func(int) bool) {
for n := 0; n < limit; n++ {
if n%2 == 0 && !yield(n) {
return
}
}
}
}
func main() {
numbers := []int{10, 20}
numbers = slices.AppendSeq(numbers, evens(7))
fmt.Println(numbers)
}The output is:
[10 20 0 2 4 6]The existing values stay at the front, and values from the sequence follow them in iteration order.
Use slices.AppendSeq when you already have a destination
A common alternative is to materialize the iterator first:
extra := slices.Collect(evens(7))
numbers = append(numbers, extra...)That works, but extra exists only to bridge the iterator and the destination. slices.AppendSeq expresses the actual operation directly:
numbers = slices.AppendSeq(numbers, evens(7))This distinction is useful in code that builds a result in stages. Suppose a configuration starts with defaults and then receives additional values from an iterator:
func buildPorts(discovered iter.Seq[int]) []int {
ports := []int{80, 443}
return slices.AppendSeq(ports, discovered)
}There is no reason to create a second slice merely to concatenate it with ports afterward.
If you don’t already have a destination and simply want a new slice containing every value from a sequence, slices.Collect is usually clearer. The two functions solve slightly different problems: Collect materializes a sequence, while AppendSeq extends a slice with one.
The returned slice matters
Treat slices.AppendSeq like append: always use the returned slice.
numbers = slices.AppendSeq(numbers, seq)Appending may exceed the capacity of numbers. When that happens, Go can allocate a new backing array and the returned slice will refer to that new storage. Ignoring the return value risks continuing to use the old slice header, which won’t reflect the appended length.
This is the same ownership rule ordinary append already has:
numbers = append(numbers, 30)AppendSeq doesn’t change slice allocation semantics just because its input arrives through an iterator.
If capacity planning matters, you can still reserve space before appending when you have a reliable size estimate:
ports := make([]int, 0, expected)
ports = slices.AppendSeq(ports, discovered)That can reduce reallocations when the estimate is meaningful. Don’t invent an oversized capacity merely because the sequence is lazy, though. For a sequence whose size isn’t known cheaply, letting the slice grow normally is often simpler.
AppendSeq consumes the iterator
Calling slices.AppendSeq is a consuming operation. It asks the sequence for values until the sequence finishes.
That matters when the iterator performs work while yielding. Consider a sequence backed by parsing records, reading generated values, or walking a data structure. AppendSeq doesn’t preserve that laziness after the call returns; the resulting slice contains all values that were yielded.
For finite data that you need to retain, that’s the point. For a conceptually unbounded iterator, it’s a serious mismatch. This sequence never finishes:
func countForever() iter.Seq[int] {
return func(yield func(int) bool) {
for n := 0; ; n++ {
if !yield(n) {
return
}
}
}
}Passing countForever() directly to slices.AppendSeq gives the consumer no natural completion point. The call keeps requesting values and the slice keeps growing until something external stops the process or resources run out.
Bound the sequence before materializing it, or keep processing it lazily if you don’t actually need every value in memory.
Empty sequences preserve the destination
An empty sequence appends nothing. In particular, the standard library specifies that an empty sequence preserves the nilness of the destination slice.
func empty() iter.Seq[int] {
return func(yield func(int) bool) {}
}
var numbers []int
numbers = slices.AppendSeq(numbers, empty())
fmt.Println(numbers == nil) // trueA non-nil empty destination remains non-nil as well:
numbers := []int{}
numbers = slices.AppendSeq(numbers, empty())
fmt.Println(numbers == nil) // falseMost application code shouldn’t depend heavily on the distinction between nil and empty slices, but it can matter at API boundaries or in tests that intentionally preserve representation. AppendSeq doesn’t erase that distinction when there is nothing to append.
Iterator errors still need an explicit design
iter.Seq yields values, not (value, error) pairs in the conventional iterator shape. slices.AppendSeq therefore has no error result of its own.
If producing values can fail, decide how that failure is represented before handing the sequence to AppendSeq. One option is to yield a result type that carries either a value or an error, then inspect those results. Another is to keep error-prone I/O outside the sequence abstraction when doing so makes control flow clearer.
Don’t assume AppendSeq can report a producer failure just because it is the consumer. Its contract is only to append the values the sequence yields.
The same caution applies to panics. If the iterator panics while being consumed, AppendSeq doesn’t turn that panic into an error. Normal panic semantics apply.
Don’t use AppendSeq when streaming is the goal
Materializing values is useful when the next operation requires indexing, repeated traversal, sorting, or an API that accepts a slice. It is unnecessary when the next step can already consume the iterator.
For example, this keeps the sequence lazy:
for port := range discovered {
if err := check(port); err != nil {
return err
}
}Turning discovered into a slice first would add storage and force the whole sequence to complete before check sees its first value. It would also prevent the loop from avoiding production of later values after an early error.
Use slices.AppendSeq at a real materialization boundary, not automatically whenever an iterator appears.
Choose the consumer that matches the next operation
slices.AppendSeq is most useful when a slice already exists and iterator-produced values belong at its end. It removes the temporary Collect-then-append pattern while retaining ordinary slice growth behavior.
Keep the returned slice, remember that the iterator is fully consumed, and avoid feeding it an unbounded sequence. If there is no existing destination, reach for slices.Collect; if the next operation can consume the sequence directly, keep it lazy instead. The useful choice is the one that matches where your program actually needs values to become stored data.