Nil Channels in Select and the Pattern They Unlock
A nil channel blocks forever, so its select case is dead. Use that to merge streams, switch cases off, and enable a send only when a batch is ready.

A nil channel sounds like a bug waiting to happen. It is the zero value of a channel type, so a struct with a forgotten make has one, and using it hangs the goroutine forever, quietly. The same property, handled on purpose, is one of the most useful tricks in channel code: if blocking forever is exactly what you want from a select case, then a nil channel is a case that switches itself off.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64)
This note shows the behavior, the three patterns that use it, and the accidental form to watch for. It follows select and channels explained, and it belongs to the concurrency material. Everything ran on Go 1.25.6.
The behavior
The language specification states it directly: a nil channel is never ready for communication. A send or receive blocks forever, and closing it panics.
package main
import (
"fmt"
"time"
)
func main() {
var ch chan int // nil
select {
case v := <-ch:
fmt.Println("received", v)
case <-time.After(30 * time.Millisecond):
fmt.Println("the nil channel case never fires")
}
defer func() { fmt.Println("recovered:", recover()) }()
close(ch)
}the nil channel case never fires
recovered: close of nil channelThe receive case never proceeds, so the timeout wins. And close on a nil channel panics with close of nil channel. Outside a select, a bare <-ch on a nil channel blocks the goroutine for good, and if every goroutine ends up blocked that way the program dies with the deadlock error (the deadlock page).
Pattern 1: merge until every input has closed
A closed channel is always ready for receive and returns zero values forever, so a loop that selects on it spins. The cure is to set the variable to nil once you see it closed, and the case disappears:
package main
import (
"fmt"
"sort"
)
func gen(vals ...int) <-chan int {
c := make(chan int)
go func() {
defer close(c)
for _, v := range vals {
c <- v
}
}()
return c
}
func main() {
a, b := gen(1, 3, 5), gen(2, 4)
var got []int
for a != nil || b != nil {
select {
case v, ok := <-a:
if !ok {
a = nil
continue
}
got = append(got, v)
case v, ok := <-b:
if !ok {
b = nil
continue
}
got = append(got, v)
}
}
sort.Ints(got)
fmt.Println(got)
}[1 2 3 4 5]Without the a = nil lines, the loop would receive 0, false from the closed a thousands of times a second while b still has data. With them, each channel drops out of the select when it closes, and the loop ends when both are nil. It is shorter than any flag-based alternative and has no extra state. The same idea scales: for three or four inputs, the code is a case per channel and a condition listing them (merging channels).
Pattern 2: enable a send only when there is something to send
A select case that sends is chosen when the channel can accept the value. To send only when you have data, make the channel variable nil when there is nothing queued. The classic form is a batcher: collect incoming items, and offer the batch downstream as soon as it exists, without blocking collection while the consumer is busy:
package main
import (
"fmt"
"time"
)
func batcher(in <-chan int, out chan<- []int, size int) {
var batch []int
for in != nil || len(batch) > 0 {
// send is enabled only when there is a full batch (or input is done and we have leftovers)
var send chan<- []int
if len(batch) >= size || (in == nil && len(batch) > 0) {
send = out
}
select {
case v, ok := <-in:
if !ok {
in = nil
continue
}
batch = append(batch, v)
case send <- batch:
batch = nil
}
}
close(out)
}
func main() {
in := make(chan int)
out := make(chan []int)
go batcher(in, out, 3)
go func() {
defer close(in)
for i := 1; i <= 8; i++ {
in <- i
}
}()
for b := range out {
time.Sleep(10 * time.Millisecond) // a slow consumer
fmt.Println(b)
}
}[1 2 3]
[4 5 6 7 8]The variable send is nil unless a batch is ready, so the send case is dead until it should fire. While the consumer sleeps, the batcher can still read from in: the receive case stays live, and the slow consumer does not stall the producer beyond the channel's own backpressure. When the input closes, in becomes nil and the leftover items go out as a final short batch. Without the nil trick you would need a goroutine for each side or a state machine with flags. The output is [1 2 3] and then [4 5 6 7 8]. The second batch has five items, not three, and that is the point of the pattern: size is a threshold for offering a batch, and while the consumer slept the batcher kept reading, so the next batch grew until the consumer came back. A fixed-size batcher would cap the batch at size instead; the nil-channel mechanism works the same either way.
Pattern 3: turn a case off after it has done its job
Any one-shot event can retire itself the same way. A timer that should fire once, a signal handled once, a channel that delivers exactly one value:
package main
import (
"fmt"
"time"
)
func main() {
ready := make(chan struct{})
tick := time.NewTicker(10 * time.Millisecond)
defer tick.Stop()
go func() {
time.Sleep(35 * time.Millisecond)
close(ready)
}()
ticks, readySeen := 0, 0
timeout := time.After(80 * time.Millisecond)
for {
select {
case <-ready:
readySeen++
ready = nil // handled; a closed channel would fire forever
case <-tick.C:
ticks++
case <-timeout:
fmt.Println("ready handled exactly once:", readySeen == 1, "| ticks > 3:", ticks > 3)
return
}
}
}ready handled exactly once: true | ticks > 3: trueready is closed, so receiving from it is always possible. Without ready = nil, the loop would handle "ready" over and over and starve the other cases of attention. Setting it to nil after the first time makes the event one-shot (the done channel is the variation where the closed channel is the signal to leave).
The accidental nil channel
The same property produces a nasty bug when the nil is not on purpose. A struct with a channel field that nobody initialised:
package main
import (
"fmt"
"time"
)
type Worker struct {
jobs chan int // forgot: jobs: make(chan int)
}
func (w *Worker) Submit(n int) {
w.jobs <- n // blocks forever on a nil channel
}
func main() {
w := &Worker{}
done := make(chan struct{})
go func() {
w.Submit(1)
close(done)
}()
select {
case <-done:
fmt.Println("submitted")
case <-time.After(50 * time.Millisecond):
fmt.Println("Submit is stuck: the channel field is nil")
}
}Submit is stuck: the channel field is nilThere is no panic and no error; the goroutine just never returns. In a service this looks like requests that hang. Constructors that create every channel they own prevent it (constructors), and a goroutine dump showing chan send (nil chan) or chan receive (nil chan) is the diagnostic: the state names it explicitly (goroutine dumps). If you see that state in a dump, a channel was never made.
How it relates to the other tools
A nil channel is one of three ways to change what a loop is waiting for, and they suit different cases. A flag variable checked before the select works when the change is a plain on/off for a whole loop, but cannot disable one case inside a select, because the select evaluates its channels anyway. A second select with default, as in the priority pattern from the select guide, reorders cases but cannot turn them off. And restructuring into goroutines, one per input, avoids the loop altogether at the cost of a goroutine each and an output channel to merge. The nil channel sits in between: no extra goroutines, no flags, a single variable that says "listen" or "don't". It is the right tool when the set of live cases changes during the loop and the loop is already a single goroutine.
The cost is readability. A reader who has not met the idiom sees a = nil and wonders whether it is a bug. A one-line comment fixes that, and so does keeping the loop short. If the number of channels or the rules for enabling them grow, switch to an explicit state variable and a small function per state; a clear state machine beats a clever one.
Testing
Tests for nil-channel loops should cover each of the transitions the nil assignment creates: the first input closing while the second still has data, the second closing first, both closing at once, and the empty case where both are already closed. For the batcher, add a case where the input closes with a partial batch pending, which checks the flush on close. Each of these takes a few lines with buffered channels and a close, and they pin down the exact behavior that is easy to break while refactoring. Run them with -race and a timeout, since a mistake in this pattern shows up as a hang.
Guidelines
Use the nil-channel trick when a select loop needs to change which cases are active over time, and comment it, because readers meet it rarely: a line such as a = nil // stop selecting on a is enough. Prefer a different structure when the cases need more than on/off, for example when a state needs several channels enabled together, because a state machine of nil assignments becomes hard to follow. And in tests, check the dead case too: send on the channel while it is nil and assert that nothing receives it. The specification's rule is in its section on channel types ("a nil channel is never ready for communication"), and the Go blog's Advanced Go Concurrency Patterns uses the technique in its bigger examples.


