The conveyor belt vs the ticket system
You write a Python script that chains .filter() and .map() across a list. You switch to Go, grab a slice of integers, and reach for an iterator object. There is none. The standard library hands you a for loop and a handful of functions in the slices package. That feels like a step backward until you see what Go is actually doing under the hood.
Go treats iteration as a control flow statement, not an object. A slice is not a linked list hiding behind a pointer. It is a three-word descriptor: a pointer to a backing array, a length, and a capacity. When you iterate over a slice, the runtime does not allocate a state machine. It reads the descriptor, steps through the contiguous memory, and yields values directly. The analogy is a factory assembly line. You do not hand each worker a separate ticket that tracks their progress. You point them at the conveyor belt and tell them to process every item until the end. The belt is already there. The movement is explicit. The cost is zero allocation per step.
Go 1.23 introduced the iter package with iter.Seq and iter.Seq2 types. Those types exist for lazy evaluation and infinite sequences. They are not meant to replace for range on slices. Slices are eager, contiguous, and fast. You iterate them with the tools built for contiguous memory.
How slices actually store data
Before writing the loop, you need to know what you are looping over. A slice variable does not hold your data. It holds a header that points to the data. The header lives on the stack. The backing array lives on the stack or the heap, depending on size and escape analysis. When you pass a slice to a function, Go copies the three-word header. The function receives a pointer to the same backing array. That copy is cheap. It takes one machine instruction.
The length tells the loop where to stop. The capacity tells the runtime how much space is reserved for future appends. When you range over a slice, the compiler reads the length once and uses it as the loop bound. It does not call a method. It does not check a flag. It increments an index and dereferences a pointer. That simplicity is why Go favors explicit loops over iterator objects. Iterator objects require heap allocation, method dispatch, and state tracking. Slices require a pointer walk.
The baseline loop
Here is the baseline iteration pattern. You pass a slice to range, and the loop hands you an index and a copy of each element.
package main
import "fmt"
func main() {
// Slice literal allocates a backing array on the stack
numbers := []int{2, 5, 8, 11, 14}
// range copies the slice header, then indexes the backing array
for i, v := range numbers {
// v is a copy. Modifying v does not change the slice
fmt.Printf("index %d: value %d\n", i, v)
}
}
What the compiler generates
The compiler translates that for loop into a tight index-based loop. It loads the slice header once. It checks the index against the length. It dereferences the pointer plus the offset. It copies the value into v. It repeats until the index equals the length. There is no interface dispatch. There is no virtual method call. The loop unrolls or vectorizes if the compiler sees a predictable pattern.
When you need to mutate the original data, you drop the value variable and use the index directly. The compiler still generates a pointer walk, but it writes through the pointer instead of copying to a temporary.
// Mutate the backing array through the index
for i := range numbers {
// Direct assignment bypasses the copy step
numbers[i] *= 2
}
The underscore convention applies here. If you only need the value, you write for _, v := range numbers. The underscore tells the compiler to discard the index. It also tells other developers that you intentionally ignored it. Go does not have a private keyword. Capitalization controls visibility. The underscore controls intent. Use it sparingly with errors, but freely with loop indices you do not need.
Functional helpers in the slices package
Real code rarely just prints values. You filter, you check conditions, you transform. The slices package provides functional-style helpers that compile down to the same tight loops. You do not need to write boilerplate index tracking. The community accepts the verbosity of explicit loops because it makes data flow visible. You see exactly where values are copied, where indices are used, and where allocations happen.
package main
import (
"fmt"
"slices"
)
// FilterEven returns a new slice containing only even numbers
func FilterEven(src []int) []int {
// Pre-allocate to avoid repeated reslicing during append
dst := make([]int, 0, len(src))
// range yields copies; we only need the value here
for _, v := range src {
// Modulo check filters out odd numbers
if v%2 == 0 {
// append grows dst and writes the value
dst = append(dst, v)
}
}
return dst
}
func main() {
ids := []int{101, 202, 303, 404, 505}
// slices.Any short-circuits on the first match
hasEven := slices.Any(ids, func(n int) bool { return n%2 == 0 })
fmt.Println("contains even:", hasEven)
// slices.IndexFunc finds the first match or returns -1
idx := slices.IndexFunc(ids, func(n int) bool { return n > 300 })
fmt.Println("first over 300 at index:", idx)
}
Realistic service layer example
Production code processes structs, not bare integers. You fetch a list of user IDs from a database, validate them, and route requests. The same iteration rules apply. Value copying protects you from accidental mutation. Indexing lets you update records. The slices package functions keep predicates isolated and readable.
package main
import (
"fmt"
"slices"
)
type Request struct {
ID string
Size int
}
// ValidateRequests filters out oversized requests and checks thresholds
func ValidateRequests(reqs []Request) ([]Request, bool) {
// Allocate destination with exact capacity to avoid reallocation
valid := make([]Request, 0, len(reqs))
// Iterate by value to avoid taking addresses of loop variables
for _, r := range reqs {
// Skip requests that exceed the payload limit
if r.Size > 1024 {
continue
}
// Append copies the struct into the new backing array
valid = append(valid, r)
}
// Check if any request targets the premium tier
hasPremium := slices.Any(valid, func(r Request) bool {
return r.ID == "premium-01"
})
return valid, hasPremium
}
func main() {
batch := []Request{
{ID: "std-01", Size: 500},
{ID: "premium-01", Size: 800},
{ID: "std-02", Size: 2000},
}
// Call the validation function and unpack the two return values
clean, isPremium := ValidateRequests(batch)
fmt.Printf("kept %d requests, premium: %v\n", len(clean), isPremium)
}
Pitfalls and compiler rejections
The for range construct copies the value into the loop variable. That copy protects you from accidental mutation, but it also means you cannot change the slice through the value variable. If you try to take the address of the value and store it, you get a pointer to the same stack location reused across iterations. Go 1.22 changed loop variable scoping to fix the classic closure capture bug, but the copy behavior remains.
Modifying the slice length or capacity while ranging over it triggers undefined behavior. The compiler does not catch this at compile time. The runtime may panic with a slice bounds out of range error, or it may silently skip elements. The compiler rejects attempts to assign to the loop variable if you declare it as a constant, but for i, v := range s makes v a fresh variable each time.
If you forget to use the index and only range over values, you cannot mutate the backing array. The compiler complains with cannot assign to v if you try. You must switch to for i := range s and index manually.
Another trap is passing a slice to a function that expects a different type. Go does not coerce between []int and []any. The compiler rejects the call with cannot use slice (variable of type []int) as []any value in argument. You must convert explicitly, which allocates a new backing array and copies every element. That allocation is real. It shows up in pprof. Plan for it.
Forget to import a package and you get undefined: slices from the compiler. Forget to use one and you get imported and not used. The Go toolchain enforces cleanliness. Trust gofmt. Argue logic, not formatting. Most editors run it on save. The community accepts the boilerplate because it makes the unhappy path visible.
When to reach for what
Use a for range loop when you need to iterate over a slice and perform side effects, mutate elements, or break early based on runtime conditions. Use slices.All or slices.Any when you need to verify a condition across the entire slice or short-circuit on the first match. Use slices.IndexFunc or slices.ContainsFunc when you need to locate an element without writing manual index tracking. Use the iter package when you are building lazy pipelines, processing infinite sequences, or composing transformations that should not allocate intermediate slices. Use plain sequential indexing when you need to step through a slice in non-uniform increments or skip elements based on complex logic.
Slices are contiguous memory. Iteration is a pointer walk. Trust the loop.