15.4 Ranging Over a Channel Until It's Closed
Right, so you’ve got a channel, you’re sending stuff into it, and you need to get everything back out. You could try to receive in an infinite loop with a select statement that has a default case to bail out, but that’s a great way to either burn CPU cycles or miss the boat entirely. The real, elegant way to do this—the way that actually understands the intent of the channel—is to use a for loop with range.
Think of for range on a channel as the polite way to wait at your mailbox. You don’t keep opening the little door every five seconds to check for mail; you just wait until the mail carrier (the sender) signals that they’re done by closing the mailbox for the day (closing the channel). This construct exists precisely for this pattern: consuming every value from a channel until the sender explicitly says, “That’s all, folks!”
Here’s the simplest, most beautiful form of it:
ch := make(chan int)
// Start a goroutine to send stuff
go func() {
for i := 0; i < 5; i++ {
ch <- i
}
close(ch) // This is the CRUCIAL part
}()
// Range over the channel until it's closed
for value := range ch {
fmt.Println(value)
}
// Output: 0, 1, 2, 3, 4
fmt.Println("Channel is closed. We're done here.")
Why Closing the Channel is Non-Negotiable
This is the single most important thing to internalize: the for range loop will run forever unless the channel is closed. It’s not like ranging over a slice where the length is known. The loop has no idea how many values are coming; its entire job is to block on the receive operation until the channel tells it no more values are coming. The only way to signal that is by closing the channel.
If you forget to close(ch), the sending goroutine will finish and then the main goroutine will deadlock, permanently stuck in the for range loop, waiting for a value that’s never coming. The runtime will eventually panic with fatal error: all goroutines are asleep - deadlock!. It’s not being dramatic; it’s right. You left it hanging.
The Zero Value Gotcha
What happens when you range over a channel that’s already closed? Nothing. The loop exits immediately without executing its body. This is actually the behavior you want. It’s a safe operation. However, this leads to a subtle point about the type of the value you’re ranging over.
ch := make(chan int)
close(ch) // Close it immediately
// This loop does nothing. Zero iterations.
for value := range ch {
fmt.Println(value) // This never runs.
}
This is why you never need to check ok in a for range loop. The range keyword handles the closed channel for you seamlessly. The syntactic sugar you use for a single receive, val, ok := <-ch, is baked directly into the range construct.
Ranging with Pointers and Mutability
Let’s say you’re sending pointers over a channel to avoid copying large structs. You need to be acutely aware of what you’re doing inside the loop.
type BigStruct struct {
Data [1_000_000]int
Name string
}
ch := make(chan *BigStruct)
go func() {
for i := 0; i < 3; i++ {
// Send a pointer to a NEW BigStruct each time
ch <- &BigStruct{Name: fmt.Sprintf("Struct #%d", i)}
}
close(ch)
}()
for item := range ch {
fmt.Println(item.Name)
// You can modify the item here, but should you?
item.Name = "I was modified!"
}
Why is this dangerous? Because if the sender goroutine is also holding onto or reusing those pointers (e.g., from a pool), you’re now mutating shared state from the receiver, which is a classic concurrency nightmare waiting to happen. The channel acted as a synchronization point for the sending of the pointer, but not for the subsequent modification of the data it points to. If you need to mutate, make sure you’re the sole owner of that data. Often, it’s safer to send values and accept the copy, or to be extremely disciplined about ownership.
The Only-Sender-Closes Rule
This can’t be repeated enough: only the sender should close a channel. Never the receiver. Closing a channel is a broadcast signal to all receivers that the stream of values has ended. If a receiver closes it, the sender—which might still be trying to send—will panic immediately with a send on closed channel error. It’s like a viewer calling “cut!” on a live TV broadcast; the actors (senders) are going to be very confused and the whole production will halt. The director (the main sending goroutine) is the one who calls the shots and says when the performance is over. Structure your code so that the goroutine responsible for sending is also the one responsible for the graceful close().