A for v := range ch loop pulls values out of a channel one at a time and stops automatically when the sender closes the channel. It replaces the awkward pattern of receiving in a loop and checking the ok flag by hand, but it shifts a responsibility onto the producer: someone has to close the channel, or the receiver waits forever. This lesson covers how range over channels works, who's allowed to close, what goes wrong when nobody does, and the bugs that come from getting the close protocol wrong.
range over a Channel Worksrange works on channels the same way it works on slices and maps, with one important twist: it doesn't iterate over indices or keys, it iterates over received values. Each pass through the loop blocks until either a value arrives or the channel is closed and drained.
The smallest useful example: a producer goroutine sends three product IDs into a channel, then closes it. The main goroutine ranges over the channel and prints each value.
The loop body runs three times, once per value, and then the loop exits cleanly because close(ids) told the receiver "no more values are coming". Without that close, the loop would block on the fourth receive and the program would deadlock.
range ch produces a single value per iteration, not the (value, ok) pair you get from v, ok := <-ch. The loop hides the ok check internally.That last point is what makes range natural for streams: you don't have to know how many values are coming, just that they'll eventually stop.
The producer pushes values into the channel, then sends a close signal. The range loop pulls values until the close arrives and there's nothing left in the buffer, at which point the loop terminates and execution moves past it.
range doesn't know how many values are coming. It can't, because the channel doesn't carry a length. The only way for the loop to terminate normally is for someone to call close(ch), which flips an internal flag the runtime checks on every receive.
You can see this directly with the longhand form of the receive. The two-value receive returns ok = false once the channel is closed and the buffer is drained.
The for v := range ch version is a shorter way of writing exactly that loop. The ok flag isn't exposed to your code, the runtime checks it for you and exits the loop when it goes false.
ok = false. The range form never delivers that zero value, it just stops iterating.This matters when you switch from a manual <-ch to range ch: if your original code relied on seeing the zero value, the range form will skip it.
If the producer never calls close, the receiver's range loop never knows that the stream is over. It blocks on the next receive, and unless something else is happening in the program, the Go runtime detects that every goroutine is blocked and kills the program with a fatal error.
The first three receives succeed. On the fourth, the range loop blocks on <-ids waiting for a value or a close. The producer goroutine has already returned (so it can't send anything more, and the channel still isn't closed). With the main goroutine blocked and no other goroutines runnable, the runtime panics with all goroutines are asleep - deadlock!.
The fix is one line:
defer close(ids) runs when the goroutine returns, so even if the producer panics or returns early, the channel still gets closed and the receiver still terminates cleanly. This is the canonical pattern: a goroutine that owns a channel as its output should defer close it at the top of its function body.
Cost: A range loop blocked on a channel that nobody will ever close is a goroutine leak. In short programs the runtime catches it as a deadlock. In long-running servers the goroutine just sits there forever, holding a stack and any captured variables. The race detector won't find this. Code review and the close-from-producer convention are your defense.
If the program has other work to do, so the runtime doesn't see a "everyone is asleep" condition, the leak is silent. The range loop blocks one goroutine forever, that goroutine holds references to whatever it was processing, and the garbage collector can't reclaim any of it. This is the kind of bug that only shows up under load, when leaked goroutines accumulate into a memory problem.
The Go convention is strict on this point: the sender closes the channel, never the receiver, and never anyone who isn't sending. The reason is mechanical, not stylistic. Closing the channel makes future sends panic. A receiver has no way to know whether other senders are still going to push values, so a receiver that closes risks crashing a sender that comes along a moment later.
Violating this rule produces a panic. Consider a worker goroutine that receives jobs and closes the channel when it's done.
This particular shape would actually panic at close(jobs) inside the worker, because jobs is already closed by the producer. The runtime panics with panic: close of closed channel. Even if the producer hadn't closed it, having the receiver close is wrong: there's no way for the receiver to know that no more senders exist.
The simpler, correct version puts the close back where it belongs:
The producer (main in this case) owns the channel's write end and is the only piece of code that calls close. The worker only receives. The sync.WaitGroup is used to make sure main doesn't exit before the worker has drained the channel.
The rule generalizes nicely:
| Situation | Who closes |
|---|---|
| Single sender, one or more receivers | The sender. |
| Multiple senders, one or more receivers | A coordinator outside the senders, after all senders have finished. Never one of the senders. |
| No clear sender (e.g., signaling) | The owner of the channel, which should be documented. |
The "multiple senders" case is the trickiest. If two goroutines are both writing to the same channel and one decides to close, the other panics on its next send. The fix is to use a separate signal (often a sync.WaitGroup waited on by a coordinator goroutine) that closes the channel only after all senders have returned. The fan-in pattern works exactly this way.
rangerange doesn't care whether the channel is buffered or unbuffered. The mechanics, send, close, receive, are identical. The visible difference is timing: buffered channels let the sender run ahead of the receiver, unbuffered ones force them to meet on every value.
The same example using an unbuffered channel:
Each send blocks until the receiver pulls the value, which is why the lines alternate. Switch to make(chan float64, 3) and the sender finishes printing all three "Sending" lines first, because the buffer accepts them without waiting.
The range loop sees the same values in the same order. The difference is whether the sender waits.
Cost: An unbuffered channel adds one goroutine handoff per send. For high-frequency producers, batching values into a buffered channel reduces context switches. Don't pick a buffer size to "go faster", pick it to model the producer-consumer rate mismatch you actually have.
A buffered channel that's been closed still delivers its remaining values before range exits. Closing doesn't discard the buffer.
Three values were queued in the buffer, the channel was closed, and range drained the buffer before exiting. This pattern, "queue everything up, close, then read", is occasionally useful when you've already computed all the values and just want a stream-shaped consumer.
Range over a channel is the cleanest way to model a single-consumer job queue. The producer pushes work items into the channel as they become available, the consumer ranges over them until the producer signals "no more work" with a close.
Imagine a script that reads a list of order IDs from somewhere (a file, an API, a database query) and processes each one. The producer reads and streams, the consumer processes and prints a summary at the end.
The producer's signature is chan<- Order, the send-only direction. This makes it a compile-time error for the producer to accidentally receive from its own output channel. The defer close(out) at the top of produceOrders is the most important line in the producer: it runs no matter how the function exits, normal return or panic, and it's the signal that lets the consumer's range loop terminate.
The consumer doesn't know or care how many orders are coming. It accumulates a count and a total as values arrive and prints a summary once the channel closes. If the producer's input source grows or shrinks, no code in the consumer needs to change.
Cost: Per-message overhead on a Go channel is small (a few hundred nanoseconds for buffered ops), but it's not zero. If you're processing millions of tiny items per second, batch them into slices and send a slice per channel op. The range loop on the consumer side then iterates the slice internally.
This shape, single producer with defer close, single consumer with range, is the most reliable channel pattern in Go, and the foundation for pipelines, fan-in, and worker pools.
range and closeThe mistakes around range and close cluster into a small set, each with a clear symptom.
close may be called exactly once per channel. A second call panics with panic: close of closed channel. The runtime has no way to know whether a re-close is "harmless"; from its perspective, the channel was already retired.
This commonly shows up when two pieces of code both think they "own" the closing responsibility, or when a function that closes its output is called twice. The fix is to make ownership explicit: one function, one close, and document who owns it.
The dual of double-close. Once a channel is closed, every send on it panics.
This is most painful in multi-sender setups, where one sender closes thinking "I'm the last one done" while another sender is still queuing up a write. The pattern is: don't have senders close. Let an external coordinator decide that all senders have finished and close once.
The receiver doesn't know what the senders are doing. If a receiver closes the channel and a sender comes along later with another value, the sender panics. Even if "later" never happens in this run, the structure invites it on the next refactor.
This particular program happens to not panic, because no further sends happen after the close. But the moment someone changes the producer to send a fourth value, the program crashes. Keep the close on the sending side.
nil Channelclose(nil) panics with panic: close of nil channel. This usually happens when a channel field on a struct is never initialized.
Always initialize channels with make, and if a function receives a channel parameter, treat nil as a programmer error rather than a value to "fix" with a close.
Already covered above, but worth a final word: a producer that just returns without closing leaves the consumer's range loop blocked. The fix is defer close(ch) at the top of the producer. Make it a reflex.
10 quizzes