1. A channel as a stream: read it fully
When you’re just getting started, it’s very tempting to treat a channel as “a variable that two threads can access”. But a channel in Go is more like a conveyor belt: one side puts values in, the other side takes them out. And, as with any conveyor belt, the most common scenario is “read until the conveyor ends”. This is exactly where for range ch shows up.
Imagine that in our app (a hypothetical CLI to-do app) there’s an operation that generates events: “task added”, “task completed”, “task deleted”. Ideally, the code that prints these events shouldn’t have to think every time: “Is the channel already closed? Is it empty right now? Is this zero because it’s really zero, or because the channel is done?”. We want to write: “print all events” — period.
Go has a “built-in” form specifically for this idea: for v := range ch { ... }. It reads almost like plain English: “for each value v received from channel ch, do...”.
2. for v := range ch: what happens “under the hood”
The for range over a channel is so short that beginners often suspect: “There must be some magic here, and it will bite me later”. Spoiler: there’s no magic. It’s just a neat wrapper around a pattern you already know: v, ok := <-ch, just without a manual break.
Almost always, you can mentally expand for range ch into a loop like this:
package main
import "fmt"
func main() {
ch := make(chan int)
close(ch)
for {
v, ok := <-ch
if !ok {
break // channel is closed and no more values will arrive
}
fmt.Println(v)
}
}
In real code you don’t want to write it this way, of course: it’s too much noise around the simple idea “read the stream to the end”. That’s why Go provides a shorter equivalent:
package main
import "fmt"
func main() {
ch := make(chan int)
close(ch)
for v := range ch {
fmt.Println(v)
}
}
The meaning is the same: the loop keeps running as long as values keep coming from the channel. As soon as the channel is closed and drained, the loop stops.
By the way, this kind of “control-flow packaging” is a very typical Go idea: first you do a simple operation, and then a form appears that lets you not think about the small details on each iteration. A very similar style exists, for example, in bufio.Scanner: you just spin the loop, and the error-handling details are moved “outside” and checked once, not on every iteration.
3. When range ends: “closed and empty”, not “temporarily empty”
Here’s the important point that causes hangs most often. for range ch does not finish because “there are no values yet”. An empty channel is not “end of data”; it’s simply “the data hasn’t arrived yet”. In the world of channels, the end of data is expressed by closing: close(ch).
To feel this concretely, it helps to fix three states in your mind:
| Channel state | What happens on receive | What for range does |
|---|---|---|
| Channel is open, buffer has values | receive returns a value immediately | continues the loop |
| Channel is open, no values | receive blocks and waits | “hangs” and waits |
| Channel is closed and no values remain | receive returns the zero value and ok=false | ends the loop |
This is especially clear with a buffered channel. close does not “erase” what has already been sent: the buffered values are still received, and only then does the loop end.
package main
import "fmt"
func main() {
ch := make(chan int, 3)
ch <- 10
ch <- 20
ch <- 30
close(ch)
for v := range ch {
fmt.Println(v) // 10 then 20 then 30
}
}
Everything here is predictable: three values come out, and then the loop ends. And this behavior is very convenient: you can close the channel, confident that the receiver will correctly “read the leftovers” and stop.
4. The “producer closes channel” pattern: one stream owner closes the channel once
Now the main plot of this lecture. For for range ch to be safe and predictable, we need a simple protocol: a single party is responsible for sending values and for closing the channel. This is the “producer closes channel” pattern.
The idea is very down-to-earth: if in an office there’s a person who issues visitor badges, that’s the same person who locks the badge cabinet at the end of the day. Visitors shouldn’t run around locking the cabinet themselves — otherwise sooner or later one person locks it, another tries to take a badge, and you get a “panic” (office version: security yelling).
In Go it looks like this: the producer creates the channel, sends values, closes the channel. The consumer only reads, usually via for range.
package main
import "fmt"
func produce(ch chan int) {
defer close(ch) // close exactly once, when we're done sending
for i := 1; i <= 3; i++ {
ch <- i
}
}
func main() {
ch := make(chan int)
go produce(ch)
for v := range ch {
fmt.Println(v) // 1 2 3
}
}
Notice the defer close(ch). It’s not a “mandatory language rule”, but as a habit it helps a lot. You pre-commit to: “I own sending, therefore I also guarantee closing”. If you add a conditional return inside produce, close will still run, and the receiver won’t hang.
5. Embedding the pattern into an app: a task stream
Let’s tie this to something that looks like real application code. Imagine we have a list of tasks and we want a function that returns tasks as a “stream” — not as a slice, but as a channel. Why can this be useful? Because a channel is easy to connect to goroutines: one piece of code “produces”, another “consumes”, and we don’t get confused about who is responsible for what.
Let’s make a minimal Task model and a producer that “streams” tasks into a channel and closes it.
package main
import "fmt"
type Task struct {
ID int
Title string
}
func streamTasks(tasks []Task) chan Task {
ch := make(chan Task)
go func() {
defer close(ch) // producer closes the channel
for _, t := range tasks {
ch <- t
}
}()
return ch
}
func main() {
tasks := []Task{{1, "Read Go book"}, {2, "Write code"}}
for t := range streamTasks(tasks) {
fmt.Println(t.ID, t.Title) // 1 Read Go book; 2 Write code
}
}
Notice how main reads: “for each task from the stream, print it”. No ok, no “when do I stop?”. Stopping is baked into the protocol: the producer is done — the producer closes the channel — range ends.
Yes, there’s a nuance here: we return a bidirectional chan Task, even though the consumer only reads. Directional channels (<-chan Task) are a separate topic, but even now you can feel why for range pairs so well with them: the receiver thinks “I’m reading a stream”, not “I’m touching a shared object”.
6. If you don’t close the channel: why the program “just waits”
The most common bug with for range ch is not a panic, but silence. The program doesn’t crash, doesn’t complain, doesn’t print anything in red: it just hangs. This is especially frustrating for beginners, because your brain expects drama and gets quiet disappointment.
The reason is almost always the same: the for range loop is waiting for the channel to be closed, but it will never be closed.
Here’s an example of code that’s “logically almost correct”, but in practice will hang:
package main
import "fmt"
func main() {
ch := make(chan int)
go func() {
ch <- 1
ch <- 2
// forgot close(ch) -> the consumer will wait for a "third value" forever
}()
for v := range ch {
fmt.Println(v) // 1, 2 and then it hangs
}
}
The fix here is not “add sleep” (please don’t), but the protocol: the producer must close the channel when the stream is done.
package main
import "fmt"
func main() {
ch := make(chan int)
go func() {
defer close(ch) // now the range loop will end correctly
ch <- 1
ch <- 2
}()
for v := range ch {
fmt.Println(v) // 1, 2
}
}
And yes, this is the case where defer doesn’t just make code “prettier”, it makes it more reliable: you leave yourself fewer chances a week later (future you, who is always a bit of a stranger) to forget to close the channel.
Flowchart of the “send…close…range” protocol
Sometimes it’s useful not only to look at code, but also to keep a small mental diagram of “who does what”. This reduces the chance of writing a program where both sides wait for each other like two polite people at a doorway (“after you”, “no, after you”), only as a deadlock.
Below is a simple flowchart of the protocol for the “one producer — one consumer” case.
flowchart TD
A[Producer: create a channel] --> B[Producer: send values ch <- v]
B --> C{Out of data?}
C -- no --> B
C -- yes --> D["Producer: close(ch)"]
D --> E[Consumer: for v := range ch]
E --> F[Consumer: process v]
F --> E
E --> G[range ended -> consumer finishes]
The key thing here is that the producer makes the decision “the data is over” and calls close. The consumer doesn’t try to guess the end of the stream at all: it just reads until range stops.
By the way, buffered channels are often used as a small “safety cushion” so that a producer can send a value and finish even if the consumer reads later. This shows up in real concurrency patterns, for example with timeouts and signaling. But for today it’s enough to remember something simple: closing defines the end of the stream, and range respects that end nicely.
7. Common mistakes with for range ch
At this point it’s easy to feel confident: the syntax is short, looks neat, almost like in a textbook. But concurrency is an area where “almost” sometimes means “not at all”. So let’s explicitly walk through the common mistakes around for range, so you can recognize them by symptoms and not spend your evening staring at a stuck terminal.
Mistake #1: for range over a channel that nobody ever closes.
This is a classic: the consumer honestly reads the stream, but the producer never calls close. As a result, the consumer hangs waiting for the next value. The cure is not hacks like time.Sleep, but discipline: if you use for range, then the channel must have an owner of closing, and closing must happen reliably.
Mistake #2: closing the channel on the receiver side “to stop range”.
Your intuition says: “I need to end the loop — I’ll close the channel”. But that’s a direct path to panic, because the producer may still try to send into an already closed channel. The “sender closes” rule is not aesthetics; it’s safety: the closer must be the one who can guarantee it will never send again.
Mistake #3: closing the channel too early.
Sometimes close(ch) is “kind of near” the sending code, but in reality it runs before the last send (for example, because of a return or incorrect conditional logic). The next send into a closed channel will cause a panic. A simple technique helps here: put defer close(ch) at the start of the producer function. Then the close happens at the end, not “whenever you happened to reach that line”.
Mistake #4: trying to mark end-of-stream with a special value.
For example: “I’ll send 0 as the completion marker” or “I’ll send an empty string”. This is almost always a bad idea, because 0 and an empty string can be valid data. As a result, you either lose correct values or complicate the protocol (“can 0 be data, or is it the end?”). Channels already have a built-in end-of-stream mechanism: closing. Use it.
Mistake #5: mixing different meanings in one channel.
If you send tasks, control commands, and error messages through the same channel, then for range becomes a poor place: the consumer has to constantly distinguish “what exactly arrived”. At that point it’s better to follow the principle “one channel — one value type — one semantic stream”. Then range stays simple, and the code stays readable.
GO TO FULL VERSION