AlgoMaster Logo

Range over Channels

High Priority16 min readUpdated June 6, 2026

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.

How range over a Channel Works

range 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.
  • Iteration ends only when the channel is closed and all buffered values have been received. A non-empty buffered channel doesn't stop a range loop, even if the sender has already closed it.
  • The receive is still a blocking operation. If the sender pauses, the loop pauses with it.

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.

Close Is the Signal That Stops the Loop

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.

  1. Range over a channel must see a close to terminate. There's no other signal.
  2. Receiving from a closed, drained channel yields the channel's zero value with 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.

Forgetting to Close: The Deadlock

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.

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.

Who Should Close: Always the Sender

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:

SituationWho closes
Single sender, one or more receiversThe sender.
Multiple senders, one or more receiversA 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.

Buffered vs Unbuffered with range

range 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.

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.

A Real Pattern: Streaming Work Items

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.

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.

Common Bugs with range and close

The mistakes around range and close cluster into a small set, each with a clear symptom.

Closing Twice

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.

Sending on a Closed Channel

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.

Closing from the Receiver

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.

Closing a nil Channel

close(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.

Letting the Sender Exit Without Closing

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.

Quiz

Range over Channels Quiz

10 quizzes