Every range
loop runs with behavior tied to the type you’re looping through. Arrays, slices, maps, and channels all support range
, but what they hand back at each step isn’t the same. How those values are created, copied, or passed around affects what your code actually works with. You could change a value and nothing happens. You could append during a loop and suddenly the whole thing shifts in an unexpected way. It doesn’t come down to luck. There’s a mechanical reason for it.
What range
gives back depends on the type. Slices, maps, and channels all have their own patterns, and Go moves things in a consistent way behind the scenes. That behavior explains a lot of weird loop bugs, and it comes directly from how the language and compiler handle memory and iteration.
How Range Works for Different Types
The range
keyword works across several types, but the result it gives you depends entirely on what you're looping over. It always follows the same structure on the outside. You loop through something with either one or two values, but what those values represent, how they’re built, and what your code can do with them changes depending on the type behind the loop.
Range Over Slices
Slices are probably the most familiar type people loop over. When you range over a slice, Go gives you two values: the index and a copy of the value at that index. That second part causes problems more than people expect. It’s not a pointer or a live view into the original data. It’s a separate copy.
Inside the loop, v
is disconnected from the original slice. Changing it doesn’t affect nums[i]
. That surprises a lot of people who think that writing to v
updates the original data.
If you want to modify the actual slice contents, you need to use the index value to access and update the element directly.
There’s another edge case that trips up a lot of code involving slices and pointers. If you try to take the address of v
in the loop, you end up getting the same memory address on every iteration. That’s because Go only allocates one copy of the loop variable, and reuses it throughout.
Now all the pointers in ptrs
point to the same value, which ends up being the last element processed. To avoid that, you need to take the address of the original element by indexing the slice.
This version builds a slice of distinct pointers, one for each value in the original nums
slice. Each pointer is tied to a separate element, and any changes made through those pointers reflect directly on the underlying slice.
Range Over Maps
With maps, the loop variables change. You still get two values, but now the first is the key, and the second is a copy of the value stored under that key.
Just like with slices, the value is only a copy. Updating v
inside the loop has no effect on the map. If you want to change values in the map, you have to assign directly to the key.
This method updates the actual map, Because you’re assigning through the original key. It also helps to know that Go doesn’t guarantee any kind of order when looping over maps. The entries can come out in any order, and that order can change between runs of the same program. This isn’t a side effect or a bug. The language does it by design to prevent code from relying on something that isn’t stable.
Now, modifying a map while you’re looping through it changes which elements the loop sees. Deleting keys during iteration never causes a panic. The loop just skips over entries that were already removed, and newly added entries might or might not show up. If you want consistent behavior, collect the keys to delete first and remove them after the loop finishes.
This separates the read phase from the mutation phase, which avoids the risk of the map structure changing while Go is walking through it.
Range Over Channels
Channels have a different pattern. When you loop over a channel, range
only returns a single value each time. That value comes from the next thing sent into the channel.
This type of loop pulls values until the channel is closed. You don’t need to track the stop condition manually. The loop ends automatically when there’s nothing left to receive. That only happens after the sender closes the channel.
If the channel is open and has no value, the loop waits. That’s fine for a consumer pattern, but it does mean you have to think about whether the producer is ever going to finish. If the channel never gets closed, the loop keeps waiting forever. There’s no automatic timeout or maximum length unless you build that in yourself.
Unlike slices and maps, channels don’t support two loop variables. There’s no index, and no built-in way to track how many items have been received. If you want to count them, you can add a counter yourself.
If you need to stop the loop early, a break
statement works just fine. That gives you the option to exit on a condition before the channel is closed. But if you want the loop to stop on its own, the only way is to close the channel from the sending side.
How Go Handles the Iteration Internals
The values you get back from a range
loop are only part of the picture. What makes these loops behave the way they do has to do with how Go handles memory and variable assignment inside the loop. Everything from how copies are made to what gets reused from one step to the next affects what your code sees and what it can safely change.
Loop Variable Copy Behavior
Go’s compiler creates a loop variable behind the scenes every time you use range
. That variable is reused on each step of the loop. This has a few consequences that only show up when you're doing things like storing addresses or closing over those values inside another function.
Here’s a quick example with strings that shows what happens when you take the address of the loop variable:
At a first, this looks like it should build a slice of three different pointers. But they all end up pointing to the same memory location. That’s because label
is the same reused variable each time through the loop, and Go just loads a different value into it on each pass.
The fix is to go through the slice directly, so you take the address of the element, not the loop variable.
Now every pointer in the result points to a different string, and updates through those pointers reflect on the original data as expected.
This same issue shows up when loop variables get closed over in goroutines. Each goroutine sees the same variable because they’re all capturing the shared memory, not a separate value from each step.
In this case, all goroutines can print the same string. You fix that by passing the value as a parameter so the goroutine gets its own copy.
That forces the function to receive a separate copy during each iteration.
Map Allocation During Looping
Go’s map internals are built around buckets, which hold the key-value entries. When you loop over a map with range
, the compiler generates code that walks through those buckets. This traversal isn't stable, and that randomness is intentional. It’s a side effect of how the buckets are accessed. The important part here is that while the keys and values are retrieved from the buckets, the actual value you get in the loop is a temporary copy. That copy doesn’t come directly from the map’s memory layout. Instead, Go loads the value, creates a temporary version of it, and hands that to your loop variable.
That’s why changing the value variable doesn’t affect the map.
Only assigning directly through the map reference does that. The compiler won’t reuse the map structure while you’re looping, but it does let you read and write using keys without issue. The trouble comes in when you try to remove entries. Deleting while looping through a map introduces problems because it can change the layout of the internal buckets during traversal.
This leads to entries being skipped or processed more than once, depending on the timing of the rehashing.
To avoid that, it’s safer to treat map iteration as read-only unless you’re only writing through existing keys. Structural changes, like removing entries or inserting enough new ones to trigger a rehash, should be kept separate from the active loop.
Channel Behavior During Range
Channel-based range loops behave differently than slices or maps. Instead of looping over a fixed collection, you’re looping over a stream of values. Each iteration pulls from the channel, and the loop waits between steps if nothing is ready.
Go translates this loop into a repeated receive operation. Behind the scenes, the compiler generates code that receives from the channel and checks whether it’s still open. If it is, it keeps going. If the channel is closed and empty, the loop ends.
That check uses Go’s built-in multi-value receive operation, which reports both the value and the open status of the channel.
This is how range
on a channel stops itself without you writing any extra condition. That ok
value tells the compiler when to exit the loop.
If the channel is never closed, the loop stalls waiting for another value. The loop has no way to guess when it’s safe to stop without the close signal. That makes closing the channel a required step in any producer that wants the loop to finish on its own.
Also, because channels don’t have indexes, the loop variable is a straight assignment from the received value. There’s no reuse or copying from an existing structure like with slices or maps.
Loop Index Allocation Behavior
Loop index variables behave a little differently from what you might expect. In a range
loop, the index variable is created once and reused every time. This means if you take its address, you get the same pointer throughout the loop.
That prints the same address on every line. This isn’t a bug. The variable is reused intentionally. Go doesn’t allocate a new copy of the index each time, because that would be wasteful and unnecessary in most cases.
But that reuse matters when you try to store or reference that variable. If you need to keep the index from a particular step, store its value, not its address.
That way each step gets its own copy and there’s no pointer sharing between loop steps. This also helps with delayed operations like goroutines, where the loop variable might otherwise change before the function runs.
Conclusion
Every time you use range
, Go sets up the same structure, but what actually happens depends on what you're looping through. Some values are copies, some are pulled from memory on the fly, and others come from a stream that pauses between steps. The details aren't random or abstract. They come straight from how the compiler handles memory, variables, and control flow. Getting familiar with that behavior helps explain why some values don’t update, why loop order changes, and why stored pointers don't always do what you expect. It all ties back to how the language moves things around while the loop runs.
If you want to see what happens when you change a slice, map, or channel while looping over it, my companion article on mutation during range loops covers that with examples: