Last Updated: May 22, 2026
A for loop with a counter is fine, but most of the time you don't actually care about the index. You want to walk through every item in a cart, every entry in a catalog, every value coming out of a channel. Go gives you a shorter, safer way to do that: the range keyword. It works on slices, arrays, maps, strings, channels, and (since Go 1.22) plain integers.
The range clause comes after a variable list and produces one or two values per iteration depending on the type you range over.
range cart produces a sequence of (index, value) pairs, and the i, item := part destructures each pair into two new variables on every iteration. The loop body runs once per element, and when the slice runs out, the loop ends.
You don't have to take both values. There are three useful forms:
The blank identifier _ is how Go says "I don't want this value". You can't use a variable name like unused because Go would then complain that you declared a variable and didn't use it. _ is a special name that the compiler accepts and immediately throws away.
There's also a fourth form, for range cart { }, with no variables at all. That just runs the loop body len(cart) times and is occasionally useful when you only care about repetition, not the elements.
For slices and arrays, range produces (index, value) pairs starting at 0. This is the form you'll write most often.
Summing a list of prices is the canonical case. You don't care about the index, so _ skips it. The loop variable p holds one price at a time, and the loop body adds it to a running total.
When you need both pieces, take both:
Index-only is handy when you want to mutate the original slice. The value you get from range is a copy, so writing to it doesn't change the underlying slice (we'll prove this in a moment). If you want to actually change elements in place, index the slice directly:
Writing back to the same slot is the main reason to pick the index-only form.
Arrays behave the same way as slices for range purposes. The only thing to watch out for is that an array is a value type in Go, so passing an array to a function (or assigning it to another variable) copies the entire thing. Ranging is fine on either, but mutating an array you got from a function won't affect the caller's copy.
This is a rule about range worth knowing. The loop variable is a fresh copy of the element, not a reference to it. Mutating the loop variable does nothing to the original.
Nothing changed. The p inside the loop is a copy of catalog[i], and p.Price *= 0.9 modifies the copy, not the original element. When the iteration ends, the copy is thrown away.
The fix is to index back into the slice:
Now you're writing to the actual slot in the backing array, and the change sticks.
The diagram makes it concrete: each iteration takes catalog[i], copies its bytes into the loop variable p, runs the body, then discards p. Writing to p writes to that throwaway copy. To mutate the real element, you need catalog[i].Price = ....
Cost: Ranging over a slice of large structs copies every element on every iteration. If Product were a 200-byte struct and you had a million of them, you'd copy 200 MB of data just to read them. For large structs, either range over a slice of pointers ([]*Product) or use index-only form (for i := range catalog) and access fields via catalog[i].Field.
For maps, range produces (key, value) pairs. The key and value form is the same shape as slice ranging, but the iteration order is randomized.
If you run this program twice in a row, you may see the keys in a different order. That's deliberate. The Go runtime intentionally randomizes the starting bucket on each range so that code doesn't accidentally depend on a particular order. If your tests pass with one order and fail with another, that's a bug you want to find early, not in production.
To skip the value, use the single-variable form:
Note the asymmetry with slices: for a slice, for x := range s gives you the index. For a map, for k := range m gives you the key. The number of variables tells range what to do, but the meaning of those variables depends on the collection type.
To skip the key (much rarer), use for _, count := range stock.
Cost: Map iteration order randomization means you cannot rely on the order of range over a map. If you need deterministic output (for logs, JSON, tests), collect the keys into a slice, sort them, and iterate the sorted slice instead.
Counting categories is a natural example:
Two range loops in the same program: one over a slice (products) to build the count map, and one over the map (counts) to print it. The slice loop is ordered, the map loop isn't.
Strings are a special case. range over a string produces (byteIndex, rune) pairs, where each iteration advances by one rune (Unicode code point), not by one byte.
Notice that the byte index jumps by more than one when a character takes multiple bytes. The string "Café" is 5 bytes in UTF-8 (é is two bytes), but it's 4 runes, and range gives you exactly 4 iterations.
A rune in Go is just an alias for int32. It represents one Unicode code point. The takeaway is: range on a string decodes UTF-8 for you and yields whole characters, not raw bytes.
If you genuinely want bytes, index the string instead:
That uses a classic for loop, not range, and productName[i] is a byte (i.e., uint8). Use this form only when bytes are what you actually want, otherwise you'll mis-handle non-ASCII characters.
Cost: range on a string allocates nothing extra, but it does perform UTF-8 decoding on every character. If you've already validated the bytes elsewhere and only need ASCII, an indexed for i < len(s) loop is faster.
A channel is Go's pipe between goroutines. range on a channel reads values out of it one at a time, blocking until a new value arrives, and stops when the channel is closed.
There's no index variable here, just one. Channels only carry values, so range produces one value per iteration. The loop exits when the channel is closed and drained.
If you forget to close(orders), this loop runs out of buffered values, blocks waiting for the next send, and the program deadlocks. Closing the channel is what tells range "no more values are coming, you can stop now".
Just know that range over a channel is the standard way to consume values from one until it's closed.
Since Go 1.22, range accepts a plain integer and runs the loop that many times, producing the values 0 through n-1.
This is equivalent to for i := 0; i < 5; i++, just shorter. It only works on integer expressions, and the loop variable is always an int starting at 0.
You can use the form without a variable too: for range 5 { ... } repeats the body 5 times without binding a counter, which is handy for retries or timed pings.
This feature requires Go 1.22 or later. If you compile with an older go.mod (go 1.21 or earlier), the compiler rejects it with an error.
For most of the lesson, the loop variable being "a copy of the element" is the whole story. But there's one important wrinkle that changed in Go 1.22, and it bites you when you mix range with closures or goroutines.
Before Go 1.22, the loop variable was a single variable reused across iterations. From Go 1.22 onward, each iteration gets a fresh variable.
The difference shows up in code like this:
What's happening: the closures all capture p. In the old behavior, p was the same variable on every iteration, just with a different value, so all three closures pointed at the same memory. By the time you called them, the loop was done and p held its final value, 30. Every closure saw the same 30.
In Go 1.22, each iteration gets its own p. The first closure captures iteration-1's p (10), the second captures iteration-2's p (20), and so on. They each remember a different value.
The same issue showed up with goroutines:
Before Go 1.22, you had to write item := item inside the loop (called "shadowing") to force a fresh variable, or pass item as an argument to the goroutine. After Go 1.22, the language does it for you.
Whether this change applies to your code depends on the go directive in your go.mod file. If it says go 1.22 or higher, you get the new behavior. If it says go 1.21 or lower, you keep the old behavior even on a newer compiler, so existing programs don't silently change meaning.
The blank identifier _ is how you tell Go "I'm taking this value but don't actually need it". It works in any position that range produces.
The reason you can't write for item, _ := range cart for a slice is that the second value of slice ranging is the value, not the index. range over a slice always produces (index, value), so dropping the value would be for i, _ := range cart, which is equivalent to for i := range cart. The shorter form is the idiomatic one.
For maps, the same logic applies: for k := range m gives you keys, and for _, v := range m gives you values only. No need for for k, _ := range m, just drop the trailing _.
There's a tiny subtlety worth knowing: _ is not a variable, it's a special syntax. You can't print it, take its address, or refer back to it later. It exists purely to satisfy the compiler when a position must be filled but the value is unwanted.
Here's a program that uses three of the forms together. It iterates a cart of products, sums prices, finds a product by name, and counts categories.
Five lines of range, each one doing a different job: summing, searching, counting, and printing. The pattern shows up so often in Go code that you'll write it without thinking once you've done it a few times.
Go 1.23 added one more thing range can iterate: a function. With the right signature, you can write for x := range myIterator { ... } where myIterator is a function you defined, and the function decides what values to yield. This makes user-defined iterators possible in idiomatic Go.
For now, just know that the things range can iterate is the list above (slice, array, map, string, channel, integer) plus functions with the right shape, starting in Go 1.23.