How to close a channel

Close a channel using the built-in `close()` function, but only when you are certain no more values will be sent and all receivers have finished processing.

The moment the stream ends

A background worker finishes processing a batch of database records. The main program is waiting for the final count. How does the main program know the work is actually done? It does not poll a boolean flag. It does not guess. It waits for the channel to close.

Closing a channel is Go's built-in broadcast mechanism for "no more data." It is a one-way signal that travels from the sender to every receiver. Once the channel is closed, the stream is finished. Receivers can drain whatever remains in the buffer, then exit cleanly. The channel variable stays in memory, but its internal state changes permanently.

What closing actually does

Think of a channel like a conveyor belt in a factory. Items move from the loading dock to the packing station. When the shift ends, the manager does not smash the conveyor. They flip a switch that stops the motor and lights up a shift over sign. Workers at the packing station keep processing items until the belt is empty, then they clock out.

In Go, close(ch) flips that switch. The runtime marks the channel as closed. Any pending sends are rejected immediately. Any pending receives continue to work until the buffer empties. After the buffer is empty, receives return the zero value for the channel's type and a boolean flag set to false.

The runtime handles the broadcast automatically. You do not need to track which goroutines are listening. Every receiver that tries to read after the buffer drains gets the same signal. This design removes the need for shared boolean flags or mutexes just to coordinate shutdown.

Closing a channel is a state change, not a deletion. The variable still exists. You can still pass it around. You just cannot send to it anymore.

The sender's responsibility

Only the sender should close a channel. This is a hard rule in Go concurrency. The sender knows when the data stream ends. The receiver only knows when it gets data. If the receiver closes the channel, the sender might still be trying to write, which triggers a runtime panic.

The standard pattern places close() inside a defer statement at the top of the sender function. This guarantees the channel closes when the function returns, whether it finishes normally or exits early due to an error.

Here is the simplest sender pattern:

// SendNumbers writes three integers to the channel and closes it.
func SendNumbers(out chan<- int) {
    // Defer ensures the channel closes even if the loop panics or returns early.
    defer close(out)
    for i := 0; i < 3; i++ {
        // Send blocks if the channel is unbuffered and no receiver is ready.
        out <- i
    }
}

The defer runs after the loop finishes. The runtime executes the close operation before the function stack unwinds. Receivers get the signal immediately after the last value is sent.

If you close a channel from the receiver side, you break the contract. The sender has no way to know the pipe is capped. It will attempt to write, and the runtime will stop the program. The sender owns the lifecycle. The receiver only consumes.

Draining the pipe

Receivers have two ways to handle a closed channel. The first is the range loop. The second is the explicit ok check. Both are idiomatic, but they serve different control flow needs.

A range loop over a channel automatically stops when the channel is closed and empty. It handles the zero-value return and the boolean flag behind the scenes. This is the cleanest way to consume a stream when you want to process everything until the end.

Here is how a receiver uses range:

// ConsumeStream prints every value until the channel closes.
func ConsumeStream(in <-chan int) {
    // Range automatically exits when the channel is closed and drained.
    for val := range in {
        fmt.Println("Got:", val)
    }
    // Execution reaches here only after the sender closes the channel.
    fmt.Println("Stream finished")
}

The range loop blocks until a value arrives or the channel closes. If the channel closes while the loop is waiting, the loop terminates immediately. You do not need to manually check for closure.

Sometimes you need more control. Maybe you want to stop after a certain condition, or you need to interleave reads from multiple channels using select. In those cases, you use the two-value receive form.

Here is the explicit check pattern:

// ReadWithControl processes values until a condition or closure.
func ReadWithControl(in <-chan int) {
    for {
        // The comma-ok idiom returns the value and a boolean indicating if the channel is open.
        val, ok := <-in
        if !ok {
            // Channel is closed and empty. Exit the loop.
            break
        }
        if val == 2 {
            // Stop early based on business logic, not channel state.
            break
        }
        fmt.Println("Processing:", val)
    }
}

The ok flag is true as long as the channel is open. When the channel closes and the buffer empties, ok becomes false and val becomes the zero value for the type. You can inspect ok to decide whether to continue, log a shutdown message, or trigger cleanup.

Go developers accept the if !ok { break } boilerplate because it makes the exit condition explicit. The compiler does not hide the control flow. You see exactly when the stream ends.

The signal pattern

Channels are not only for passing data. They are also excellent for signaling completion. When you do not care about the payload, you use a channel of empty structs: chan struct{}.

An empty struct takes zero bytes of memory. The compiler optimizes it away. You get a lightweight synchronization primitive that only carries state: open or closed.

Here is the standard signal pattern:

// WaitUntilDone blocks until the background task signals completion.
func WaitUntilDone() {
    // Zero-size struct channel uses no memory for the payload.
    done := make(chan struct{})
    go func() {
        // Simulate background work that takes time.
        time.Sleep(2 * time.Second)
        // Closing unblocks every goroutine waiting on this channel.
        close(done)
    }()
    // Blocks here until the goroutine closes the channel.
    <-done
    fmt.Println("Work finished")
}

The <-done receive blocks until the channel closes. It does not read a value. It just waits for the state change. Multiple goroutines can wait on the same done channel. They all unblock simultaneously when the sender calls close().

This pattern replaces busy-wait loops, shared boolean flags, and complex mutex coordination. The runtime handles the wake-up. You just close the channel when the work is done.

Signal channels follow the same sender-only rule. The goroutine that finishes the work closes the channel. Waiting goroutines never close it. If a waiter closes it, the sender might panic, or other waiters might unblock prematurely. Keep the lifecycle strict.

When the runtime panics

Channels are safe by design, but they enforce their rules at runtime. Two operations trigger immediate panics.

Sending to a closed channel stops the program with panic: send on closed channel. The runtime catches this the moment the send operation evaluates. It does not wait for the value to reach the buffer. If your code path allows a sender to run after a close, you will see this panic during testing.

Closing an already closed channel stops the program with panic: close of closed channel. This happens when two goroutines race to close the same channel, or when a function calls close() twice. The runtime does not make close() idempotent. It treats double closes as a logic error.

The compiler cannot catch these panics because channel state is dynamic. The compiler only checks types and syntax. It sees close(ch) and assumes the programmer knows the channel is open. The burden of correctness falls on the concurrency design.

You avoid these panics by following three rules. First, only the sender closes. Second, use defer close(ch) so the close happens exactly once at function exit. Third, never close a channel that multiple goroutines might try to close. If you need a shared shutdown signal, use a context.Context or a sync.Once wrapper, not a raw channel close.

Goroutine leaks happen when a receiver waits on a channel that never closes. Always design a path to closure. If the sender exits early, the defer still runs. If the sender panics, the defer still runs. The channel closes. The receiver wakes up. The program continues.

Picking the right approach

Concurrency patterns overlap. You choose based on control flow, buffer size, and shutdown requirements.

Use a range loop when you want to consume an entire stream until the sender finishes. Use an explicit ok check when you need to interleave channel reads with select or break early based on data values. Use a chan struct{} signal when you only need to coordinate timing and do not care about payloads. Use a buffered channel when you want the sender to return immediately after writing a few values before closing. Use an unbuffered channel when you want strict synchronization between each send and receive. Use defer close(ch) in every sender function to guarantee cleanup. Use context.Context when you need to cancel work across multiple goroutines or add deadlines.

Channels are state machines. Treat them like one.

Where to go next