Performance of Iterators vs Slices in Go

Slices outperform iterators in Go due to lower overhead, making them the default choice for simple iteration tasks.

The loop that slowed down

You're refactoring a data processing pipeline. The old code has a messy nested loop over a slice. You remember Go 1.23 added iterators. You wrap the slice in a closure, chain a filter, and run the benchmark. The result stings: the iterator version is slower. Maybe not by much, but enough to make you question the refactor. Is the new feature a trap? Or did you just misuse it?

The answer depends on what the loop actually does. Slices win on raw throughput because they map directly to memory. Iterators win on flexibility and allocation control because they decouple the data source from the consumption logic. The performance gap comes from function calls, closure allocations, and compiler inlining limits. Understanding where those costs live tells you exactly when to reach for each tool.

Slices are memory. Iterators are control flow.

A slice is a contiguous block of memory. The CPU knows the start address, the length, and the stride. It can fetch multiple values in a single instruction and predict the next address perfectly. A range loop over a slice compiles to a tight machine code loop that walks memory linearly. There are no function calls per element. The compiler can unroll the loop and use vector instructions to process four or eight values at once.

An iterator is a function that produces values one at a time. The iter.Seq[T] type is defined as func(yield func(T) bool). When you range over an iterator, the runtime calls your function. Your function calls yield for each value. yield is itself a closure managed by the runtime. Every value requires a jump to your closure, a jump to yield, and a jump back. That indirection breaks the CPU's prefetcher and prevents vectorization. The data might be the same, but the control flow adds overhead.

Think of a slice like a row of lockers. You walk down the row and grab what you need. An iterator is like a vending machine. You press a button, the machine spins, drops one item, and waits for the next button press. The items are identical. The mechanism changes the speed.

Minimal comparison

Here's the simplest case. A standard range loop over a slice versus an iterator wrapping that same slice. The iterator is a closure that captures the slice and yields each element.

package main

import "iter"

func main() {
    data := []int{1, 2, 3, 4, 5}

    // Slice loop: compiler optimizes this to a tight memory walk.
    // No function calls per element. Bounds check eliminated.
    for _, v := range data {
        _ = v
    }

    // Iterator: closure captures the slice.
    // Each yield call invokes the closure logic and the runtime yield function.
    seq := func(yield func(int) bool) {
        for _, v := range data {
            if !yield(v) {
                return
            }
        }
    }

    for _, v := range seq {
        _ = v
    }
}

The slice loop generates zero allocations and minimal instructions. The iterator loop allocates a closure for the yield function on the heap. That allocation happens once per loop, but it still touches the garbage collector. If the iterator closure captures variables, it may also escape to the heap. The slice loop keeps everything on the stack or in registers.

What happens under the hood

The compiler treats slice range loops as a primitive. It knows the length at runtime, so it can eliminate bounds checks inside the loop. It can reorder loads and stores to maximize cache utilization. If the loop body is simple, the compiler inlines it and vectorizes the operations. The result is code that runs at the speed of memory bandwidth.

The iterator loop is a function call. The range statement invokes the iterator function, passing a yield closure. The iterator function calls yield for each value. yield returns a boolean indicating whether the consumer wants more values. If yield returns false, the iterator must stop. This contract allows early termination, but it requires checking the return value on every step. The compiler can sometimes inline the iterator if the closure is small and the loop is simple, but complex chains or generic types often block inlining. When inlining fails, the function call overhead dominates.

The yield closure is allocated on the heap because it escapes the iterator function. This allocation is small, but it accumulates in hot paths. The garbage collector has to scan and sweep these closures. In a tight loop processing millions of items, the allocation rate can trigger GC pauses. The slice loop generates no allocations, so it puts no pressure on the collector.

Realistic example: filtering a user list

Here's a realistic case. You're building an API that returns active users from a large cache. You want to filter by status and map to a response struct. The iterator approach avoids allocating a new slice for the filtered result.

package main

import "iter"

type User struct {
    ID     int
    Name   string
    Active bool
}

// FilterActive returns an iterator over active users.
// This avoids allocating a new slice for the filtered result.
func FilterActive(users []User) iter.Seq[User] {
    return func(yield func(User) bool) {
        for _, u := range users {
            if u.Active && !yield(u) {
                return
            }
        }
    }
}

func main() {
    users := []User{{1, "Alice", true}, {2, "Bob", false}, {3, "Charlie", true}}

    // Iterator approach: lazy evaluation.
    // Only processes users that match. No intermediate slice allocation.
    for u := range FilterActive(users) {
        _ = u.Name
    }
}

The FilterActive function returns an iter.Seq[User]. The caller ranges over it and gets only active users. No temporary slice is created. If the caller breaks after the first match, the iterator stops immediately. This saves work and memory. The trade-off is the function call overhead per user. If you process the entire list and the loop body is cheap, the slice loop might still be faster despite the allocation. If you break early or the list is huge, the iterator wins.

The iter package introduces a convention. Iterator functions return iter.Seq[T]. The closure signature is fixed. You don't name a receiver because it's a function return, not a method. This keeps the API uniform across the standard library. The gofmt tool formats the closure consistently, so you don't argue about indentation inside the iterator body.

Pitfalls and errors

Iterators introduce new failure modes. The compiler enforces the signature strictly. If your closure returns a value or takes extra arguments, you get cannot use ... as iter.Seq[T] value in return. The signature must be exactly func(yield func(T) bool). You can't add a context parameter to the yield function. If your iterator performs blocking work, you must capture the context in the closure and check for cancellation manually.

The yield function returns a boolean. True means continue. False means the consumer broke out of the loop. If you ignore this return value, your iterator keeps running and producing values nobody wants. This wastes CPU cycles and can cause deadlocks if the iterator blocks waiting for more data. Always check if !yield(v) { return }. The compiler won't stop you from ignoring the return value. It's a logic bug that shows up in benchmarks or hangs.

If you pass a value that isn't a slice, map, channel, or iterator function, the compiler rejects it with cannot range over type T. You can't just wrap anything in an iterator without the correct signature. The range statement checks the type at compile time.

Iterators don't support random access. You can't sort an iterator without collecting it into a slice first. If you need to sort or index the data, the iterator adds an allocation step that defeats the purpose. The slice loop gives you direct access to indices and values.

The worst iterator bug is the one that never logs. If the iterator leaks a goroutine or holds a lock, the program hangs silently. Always ensure the iterator has a cancellation path. If the consumer breaks, the iterator must release all resources.

Check the yield return. An iterator that ignores the break signal is a CPU hog.

When to use what

Use a slice loop when you need maximum throughput and the data fits in memory. The compiler optimizes range loops aggressively, often generating vectorized instructions that process multiple elements per cycle.

Use an iterator when you need lazy evaluation to avoid allocating intermediate slices. Iterators shine when chaining filters and maps over large datasets where you only need the first few results.

Use an iterator when you want to abstract the data source. An iterator can wrap a slice, a database query, or a network stream behind the same iter.Seq[T] interface, letting callers write uniform loop logic.

Use a slice loop when the iteration logic is simple and inlining matters. Complex iterator chains prevent the compiler from inlining the loop body, which adds function call overhead that can dominate runtime in tight loops.

Use an iterator when you need to break early without penalty. With a slice, you might allocate a filtered result before realizing you only need one match. An iterator stops producing values the moment the consumer breaks, saving work.

Use a slice loop when you need random access or sorting. Iterators are forward-only streams. You can't index into an iterator or sort it without collecting it first.

Performance is a spectrum. Measure the loop, not the feature.

Where to go next