The conveyor belt that stops on its own
You are building a background job processor. One goroutine reads tasks from a database, another transforms them, and a third writes the results to disk. The producer finishes its batch and needs to tell the consumer to shut down gracefully. You could spin up a polling loop checking a boolean flag every millisecond, or you could let Go handle the coordination. Channels exist for this exact handoff.
How ranging actually works
Ranging over a channel is Go's built-in way to drain a stream of values until the stream ends. The for val := range ch syntax blocks until a value arrives, assigns it to val, runs the loop body, and repeats. When the sender calls close(ch), the loop automatically breaks after processing any remaining buffered items. Think of it like reading lines from a standard input pipe. The program waits for data, processes it, and exits cleanly when the upstream process closes the pipe. No busy waiting, no manual state tracking.
The compiler translates for val := range ch into a tight loop containing a single receive operation. At runtime, the Go scheduler watches the channel state. If the channel is open and empty, the receiving goroutine parks itself. The scheduler puts it to sleep until another goroutine sends a value or closes the channel. When a value arrives, the scheduler wakes the receiver, assigns the value, and executes the loop body. If the channel closes while items are still buffered, the loop drains those items first, then breaks. The zero value of the channel type is never yielded after closure. This parking and waking mechanism is what makes Go concurrency efficient. The OS thread isn't spinning. It's doing actual work elsewhere while your goroutine waits.
Ranging is the idiomatic way to consume a channel. The community expects it because it reads like English and removes boilerplate.
Minimal example
Here is the simplest pattern: spawn a sender, range over the channel in the main goroutine, and let the loop exit on its own.
package main
import "fmt"
func main() {
// buffered so the sender can fire off three values without blocking
ch := make(chan int, 3)
// sender runs concurrently and closes the channel when finished
go func() {
for i := 1; i <= 3; i++ {
ch <- i
}
// closing signals to the receiver that no more values are coming
close(ch)
}()
// range automatically blocks on receives and breaks when ch closes
for val := range ch {
fmt.Printf("Got: %d\n", val)
}
fmt.Println("Done processing.")
}
# output:
Got: 1
Got: 2
Got: 3
Done processing.
Realistic example with early exit
Real programs rarely process a fixed batch of integers. They handle streams that might need early termination or conditional logic. Here is a worker that reads log lines, filters out debug messages, and stops if it encounters a shutdown signal.
package main
import "fmt"
// processLogs reads from a channel and filters messages based on level
func processLogs(input <-chan string) {
for {
// two-value receive returns the value and a boolean indicating if the channel is open
msg, ok := <-input
if !ok {
// channel closed, exit the function cleanly
return
}
if msg == "SHUTDOWN" {
fmt.Println("Received shutdown signal, stopping early.")
break
}
fmt.Println("Processing:", msg)
}
}
func main() {
logStream := make(chan string, 5)
go func() {
logStream <- "INFO: server started"
logStream <- "DEBUG: checking cache"
logStream <- "SHUTDOWN"
logStream <- "INFO: this never arrives"
close(logStream)
}()
processLogs(logStream)
fmt.Println("Worker exited.")
}
# output:
Processing: INFO: server started
Processing: DEBUG: checking cache
Received shutdown signal, stopping early.
Worker exited.
The val, ok := <-ch idiom gives you manual control. The ok boolean is true when a value is delivered and false when the channel is closed and empty. You can drop the value with an underscore if you only care about the signal: _, ok := <-ch. The convention is to name the receiver parameter with a short, descriptive name like input or ch, not this or self.
Pitfalls and runtime traps
The range keyword is convenient, but it hides a few traps. The most common is forgetting to close the channel. If the sender finishes its work but skips close(ch), the receiver stays parked forever. The runtime eventually detects that no goroutines can make progress and halts the program with fatal error: all goroutines are asleep - deadlock!. Always close from the sender side. The receiver should never call close. Attempting to close a channel you only read from triggers a compile error: cannot close receive-only channel.
Another trap is closing a channel while multiple goroutines are still writing to it. Go does not allow concurrent closes. If two senders race to call close, the program panics with panic: close of closed channel. The convention is strict: the goroutine that owns the channel's lifecycle closes it. If multiple workers feed into a single channel, use a sync.WaitGroup to track when the last sender finishes, then close it in a separate coordinator goroutine.
Goroutine leaks also happen when you assume a channel will close but the upstream logic exits early. If a goroutine blocks on for val := range ch and the channel never closes, that goroutine lives until the process terminates. The worst goroutine bug is the one that never logs. Trust the close signal. If you cannot guarantee closure, switch to a different control flow.
When to use which pattern
Use for val := range ch when you need to drain a channel completely and the sender controls the lifecycle. Use the val, ok := <-ch pattern inside a manual for loop when you need to break early based on runtime conditions or inspect the channel state without blocking. Use a select statement with a default case when you want to poll a channel without blocking the current goroutine. Use a context.Context cancellation when you need to coordinate shutdown across multiple independent goroutines that do not share a single channel.