How to Range Over a Channel in Go

You range over a channel by using the `for range` loop, which automatically receives values until the channel is closed.

The stream that know when to stop

A background worker sits idle, waiting for the next task. The producer finished its job ten seconds ago, but the consumer has no way to know. It blocks forever, holding onto a thread, leaking memory, and eventually triggering a deadlock in a long-running service. This is the most common concurrency trap for developers new to Go.

Ranging over a channel solves it in three lines. The for range loop on a channel is not just syntactic sugar. It is a built-in contract between sender and receiver. The sender promises to close the channel when the stream ends. The receiver promises to keep reading until that signal arrives. When the channel closes, the loop exits cleanly without a single manual check.

What ranging over a channel actually does

A channel is a typed pipe between goroutines. You send values into one end and receive them from the other. The range keyword turns that pipe into an iterator. Under the hood, Go compiles for val := range ch into a loop that repeatedly executes a receive operation and checks whether the channel is still open.

Think of it like a radio broadcast. The station plays songs until the manager flips the off switch. You do not need to constantly ask the DJ if the show is over. You just listen. When the signal cuts, you know the broadcast is finished. The channel works the same way. The close() call flips the switch. The range loop hears the silence and stops.

This pattern removes the need for manual ok checks, eliminates race conditions around shared boolean flags, and keeps the receiving code focused on processing data instead of managing state.

The minimal pattern

Here is the simplest producer-consumer setup. One goroutine generates numbers, the main goroutine consumes them.

package main

import (
	"fmt"
	"time"
)

func main() {
	// unbuffered channel blocks until both sides are ready
	tasks := make(chan int)

	// producer runs in a separate goroutine to avoid blocking main
	go func() {
		for i := 1; i <= 3; i++ {
			tasks <- i
			time.Sleep(100 * time.Millisecond)
		}
		// sender closes the channel to signal completion
		close(tasks)
	}()

	// range automatically receives until the channel closes
	for t := range tasks {
		fmt.Println("Processing:", t)
	}

	fmt.Println("All tasks done.")
}

The producer sends three integers, pauses briefly between each, and then calls close(tasks). The receiver never checks a condition. It just loops. When the third value arrives, the next iteration attempts to receive. The runtime sees the channel is closed, returns the zero value for the type, and exits the loop. The program prints the final message and exits cleanly.

How the runtime handles the loop

Go compiles a channel range loop into a tight for statement that uses the two-value receive idiom. Every iteration performs val, ok := <-ch. If ok is true, the loop body runs. If ok is false, the loop terminates. You never write that check manually because the compiler generates it for you.

The scheduler plays a key role here. When the receiver calls range and the channel is empty, the goroutine parks itself. It releases the OS thread and waits. The moment the sender pushes a value or closes the channel, the scheduler wakes the receiver and resumes execution. This parking and waking happens at the language level, not the operating system level. You get lightweight concurrency without thread exhaustion.

If the channel is buffered, the behavior shifts slightly. The receiver will drain the buffer immediately, then park when it empties. The close() call still triggers the exit, but any values already sitting in the buffer get processed before the loop ends. This makes buffered channels useful for smoothing out bursts of data without changing the receiver logic.

Convention note: only the sender should call close(). If a receiver tries to close a channel, you introduce race conditions and potential panics. The community treats channel closure as a one-way signal. The producer owns the lifecycle. The consumer just listens.

Real-world: processing a batch of jobs

Production code rarely processes raw integers. It handles structs, HTTP requests, or database rows. Here is a realistic worker that reads job definitions from a channel, processes them, and tracks completion.

package main

import (
	"fmt"
	"sync"
)

type Job struct {
	ID   int
	Data string
}

func processJobs(in <-chan Job) {
	// read-only channel enforces direction at compile time
	for j := range in {
		fmt.Printf("Job %d: %s\n", j.ID, j.Data)
	}
}

func main() {
	jobs := make(chan Job, 2)
	var wg sync.WaitGroup

	// spawn two workers to consume concurrently
	for w := 1; w <= 2; w++ {
		wg.Add(1)
		go func(workerID int) {
			defer wg.Done()
			// each worker ranges over the same channel
			for j := range jobs {
				fmt.Printf("Worker %d handling job %d\n", workerID, j.ID)
			}
		}(w)
	}

	// feed jobs into the channel
	for i := 1; i <= 5; i++ {
		jobs <- Job{ID: i, Data: fmt.Sprintf("task-%d", i)}
	}
	close(jobs)

	// wait for all workers to finish draining
	wg.Wait()
	fmt.Println("Batch complete.")
}

The channel is buffered to two so the producer can push a couple of jobs without blocking. Two worker goroutines range over the same channel. Go's runtime distributes values fairly: each receive operation pulls the next available item, so work spreads across workers automatically. The sync.WaitGroup ensures the main function waits until both workers finish draining the channel after it closes.

This pattern scales. You can add ten workers or a hundred. The range loop handles load balancing without extra bookkeeping. The only requirement is that the producer closes the channel exactly once when the work is done.

When range fails and what breaks

Ranging over a channel is elegant, but it assumes a closed stream. If that assumption breaks, the program stalls or crashes.

The most common failure is forgetting to close the channel. The receiver blocks on the next iteration, waiting for a value that never arrives. If no other goroutine can make progress, the runtime detects the stall and panics with fatal error: all goroutines are asleep - deadlock!. This happens frequently in tests where a mock producer returns early without closing the channel.

Closing a channel twice triggers an immediate panic. The runtime checks the channel state before allowing the close operation. If it is already closed, you get panic: close of closed channel. This usually happens when multiple goroutines share a write-only channel and each tries to signal completion. The fix is to centralize closure logic. Use sync.Once or a single dedicated closer goroutine.

Ranging over a channel that might not close requires a different approach. Long-running services, WebSocket streams, or live data feeds rarely end. If you use range on an open-ended stream, the goroutine lives forever. You cannot cancel it with a context, and you cannot shut it down gracefully. In those cases, you must abandon range and use a select statement with a timeout or a done channel.

Convention note: Go favors explicit error handling and visible control flow. The if err != nil pattern exists for the same reason close() exists. Make the boundaries clear. If a stream can end unexpectedly, handle it. If it runs indefinitely, manage it with cancellation signals instead of relying on closure.

Choosing the right receiver pattern

Pick the iteration strategy that matches your data lifecycle. Each pattern solves a specific concurrency shape.

Use for range on a channel when the producer knows exactly when the stream ends and can safely close the channel. Use a select with a done channel when you need to cancel a long-running receiver before the stream finishes. Use a select with time.After when you must enforce a timeout on a channel that might hang or stall. Use explicit val, ok := <-ch in a manual loop when you need to interleave channel receives with other blocking operations like file I/O or database queries. Use a buffered channel with range when the producer generates data in bursts and you want to decouple send and receive rates. Use a single goroutine with a channel when one task feeds another in a strict pipeline. Use plain sequential code when you do not need concurrency: the simplest thing that works is usually the right thing.

Where to go next