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 Matrix
| Operation | Nil channel | Unbuffered | Buffered | Closed |
|---|---|---|---|---|
| Receive | Blocks forever | Blocks until a sender arrives | Completes if not empty, otherwise blocks | Completes immediately with the zero value |
| Send | Blocks forever | Blocks until a receiver arrives | Completes immediately if not full, otherwise blocks | Panic |
| Close | Panic | Completes immediately | Completes immediately | Panic |
Three rules to memorize:
- Operating on a
nilchannel blocks forever (exceptclose, which panics). - Sending to or closing a closed channel panics.
- 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
nilchannel in aselectis 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.