Channels are Go’s primary tool for coordinating goroutines, but their behavior changes drastically depending on their state. A channel can be nil, unbuffered, buffered, or closed — and the same operation (send, receive, close) does something different in each case. Getting this matrix wrong is how deadlocks and panics happen.

The Behavior of Channels

The Behavior Matrix

OperationNil channelUnbufferedBufferedClosed
ReceiveBlocks foreverBlocks until a sender arrivesCompletes if not empty, otherwise blocksCompletes immediately with the zero value
SendBlocks foreverBlocks until a receiver arrivesCompletes immediately if not full, otherwise blocksPanic
ClosePanicCompletes immediatelyCompletes immediatelyPanic

Three rules to memorize:

  1. Operating on a nil channel blocks forever (except close, which panics).
  2. Sending to or closing a closed channel panics.
  3. Receiving from a closed channel always succeeds and returns the zero value.

The rest of this post walks through each column with runnable examples.

Nil Channels

A channel declared but never initialized is nil. Reads and writes block forever; closing panics.

var ch chan int // nil

go func() {
    ch <- 1 // blocks forever
}()

<-ch // blocks forever

This isn’t just a footgun — it’s a feature. Setting a channel to nil inside a select disables that case, which is the canonical way to dynamically turn branches on and off:

func merge(a, b <-chan int) <-chan int {
    out := make(chan int)
    go func() {
        defer close(out)
        for a != nil || b != nil {
            select {
            case v, ok := <-a:
                if !ok {
                    a = nil // disable this case
                    continue
                }
                out <- v
            case v, ok := <-b:
                if !ok {
                    b = nil
                    continue
                }
                out <- v
            }
        }
    }()
    return out
}

Unbuffered Channels

make(chan T) creates an unbuffered channel. Send and receive are synchronous: each side blocks until the other shows up. This is a synchronization primitive as much as a data pipe.

ch := make(chan string)

go func() {
    ch <- "ping" // blocks until main goroutine receives
}()

msg := <-ch // unblocks the sender
fmt.Println(msg)

Use unbuffered channels when the handoff itself is the signal — when you need a guarantee that the receiver has acknowledged the value before the sender moves on.

Buffered Channels

make(chan T, n) creates a buffer of capacity n. Sends complete immediately while there’s room; receives complete immediately while there’s data. The channel only blocks when the buffer is full (sender) or empty (receiver).

ch := make(chan int, 2)

ch <- 1 // ok, buffer: [1]
ch <- 2 // ok, buffer: [1, 2]
// ch <- 3 // would block — buffer full

fmt.Println(<-ch) // 1, buffer: [2]
fmt.Println(<-ch) // 2, buffer: []

Buffered channels are useful for decoupling producers and consumers, or for bounded work queues. Be careful: a buffer doesn’t prevent deadlocks, it just delays them. If your buffer is sized for “average” load and your producer outpaces the consumer, you’ll still block.

Closed Channels

Closing a channel signals “no more values will ever be sent.” Receivers can drain remaining buffered values, then start receiving the zero value.

ch := make(chan int, 2)
ch <- 10
ch <- 20
close(ch)

fmt.Println(<-ch) // 10
fmt.Println(<-ch) // 20
fmt.Println(<-ch) // 0 (zero value, immediately)

v, ok := <-ch
fmt.Println(v, ok) // 0 false — the comma-ok idiom detects closure

This is what makes for range on a channel terminate cleanly:

ch := make(chan int)
go func() {
    for i := 0; i < 3; i++ {
        ch <- i
    }
    close(ch) // loop ends here
}()

for v := range ch {
    fmt.Println(v) // 0, 1, 2
}

The Two Things That Panic

Two operations on a closed channel will crash your program:

close(ch)
ch <- 1   // panic: send on closed channel
close(ch) // panic: close of closed channel

This drives one of Go’s most important channel rules: only the sender should close a channel, and only when no more sends will happen. If multiple goroutines send on the same channel, you need separate coordination (a sync.WaitGroup is the usual answer) to know when it’s safe to close.

The Sender-Closes Pattern

Here’s the canonical fan-out pattern that respects the closing rule:

func produce(ctx context.Context) <-chan int {
    out := make(chan int)
    go func() {
        defer close(out) // sender closes
        for i := 0; ; i++ {
            select {
            case <-ctx.Done():
                return
            case out <- i:
            }
        }
    }()
    return out
}

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
    defer cancel()

    for v := range produce(ctx) {
        fmt.Println(v)
    }
}

The producer owns the channel, owns the close, and uses ctx for cancellation. The consumer just ranges. No panics, no leaks.

Closing as a Broadcast

Because every receiver on a closed channel unblocks immediately, closing is also a cheap broadcast mechanism. This is exactly how context.Done() works:

done := make(chan struct{})

for i := 0; i < 5; i++ {
    go func(id int) {
        <-done // all 5 unblock simultaneously when done is closed
        fmt.Printf("worker %d shutting down\n", id)
    }(i)
}

close(done) // broadcast to all workers

Putting It Together

The matrix isn’t trivia — it’s the contract. When you read channel code, mentally tag each channel with its current state and the matrix tells you exactly what each line does:

  • A nil channel in a select is a disabled branch.
  • An unbuffered channel is a synchronous handoff.
  • A buffered channel is a bounded queue.
  • A closed channel is a one-way signal.

Master those four states and most of Go’s concurrency idioms — pipelines, fan-in/fan-out, cancellation, broadcast — fall out naturally.