A send or receive on a nil Go channel can never proceed. Inside a select, that property removes the associated communication case from the set of cases eligible to run, without requiring a separate condition around the select.

This behavior follows directly from the channel contract. The zero value of a channel is nil, and a nil channel is never ready for communication. A standalone send or receive therefore blocks indefinitely. A select treats the same operation as a case that cannot currently proceed.

Nil changes eligibility rather than syntax

Consider a loop that can receive from two sources:

for {
    select {
    case v := <-primary:
        consume(v)
    case v := <-secondary:
        consume(v)
    }
}

Both cases participate while both channel variables refer to active channels. Assigning one variable to nil changes the set of possible communications:

secondary = nil

The second case remains present in source code, but it cannot be selected. The select can proceed only through the first case, or block if that case is also unavailable.

This is distinct from closing a channel. A closed channel is ready for receive operations immediately after buffered values are drained. A nil channel is never ready. Replacing a closed input with nil can therefore prevent a loop from repeatedly selecting a permanently ready closed channel.

Closed and nil channels have opposite receive behavior

Channel state has a large effect on receive eligibility:

Channel state Receive behavior
Open, value available Proceeds with the value
Open, no value available Blocks
Closed, buffered value available Proceeds with the buffered value
Closed, buffer empty Proceeds immediately with the zero value and ok == false
Nil Blocks indefinitely

A common multi-input loop uses the two-value receive to detect closure, then clears that channel variable:

for left != nil || right != nil {
    select {
    case v, ok := <-left:
        if !ok {
            left = nil
            continue
        }
        consume(v)

    case v, ok := <-right:
        if !ok {
            right = nil
            continue
        }
        consume(v)
    }
}

Without the assignment to nil, a closed channel remains immediately selectable. The loop can repeatedly enter that case, receive zero values, and consume scheduling opportunities that should belong to still-active inputs.

The nil assignment converts an exhausted input from permanently ready to permanently ineligible.

Output channels can use the same state transition

The mechanism also applies to sends. A send on a nil channel cannot proceed, so an output case can be enabled only while a value is pending:

var out chan<- Item
var next Item

for {
    select {
    case next = <-input:
        out = destination

    case out <- next:
        out = nil
    }
}

When out is nil, the send case cannot run. Receiving an item assigns a real destination channel and makes the send eligible whenever that destination can accept the value. After the send completes, setting out back to nil removes the case again.

This pattern represents protocol state through channel values. The communication operation and its eligibility stay adjacent in the select, while ordinary assignments determine which transitions are currently possible.

Evaluation still occurs when the channel value is nil

Disabling a case through a nil channel does not mean the case expression is skipped during select setup. The language specifies that channel operands, and the right-hand-side expressions of send statements, are evaluated when execution enters the select.

For example:

select {
case channelFor(id()) <- payload():
    sent()
case <-done:
    stopped()
}

If channelFor returns a nil channel, that send cannot proceed. Calls used to compute the channel and send value have still been evaluated before case selection.

This boundary matters when those expressions mutate state, allocate resources, perform logging, or have other visible effects. Nil controls communication readiness; it does not provide lazy evaluation for the expressions that construct a case.

A select can become permanently blocked

If every communication case uses a nil channel and there is no default, no case can proceed:

var a <-chan int
var b chan<- int

select {
case <-a:
case b <- 1:
}

The select blocks indefinitely. This can be deliberate in narrow runtime structures, but it can also emerge from state transitions that disable every case while leaving the goroutine expected to make progress.

A default changes that boundary. When no communication case is ready, default executes immediately, including when every channel is nil. That converts a blocking state machine into a non-blocking poll at that point in execution.

Nil channels encode local control state

Using nil channels for case control is effective when channel availability already represents the state of a concurrent protocol. It avoids duplicating a select into several conditional variants as inputs close, outputs become pending, or phases change.

The same property can obscure progress conditions when assignments are spread across a large function. A channel variable then carries two roles: the communication endpoint and the eligibility bit for its case. Keeping those assignments close to the relevant select makes the state transition visible.

Nil is therefore not merely an uninitialized channel value. In a select, it is a precise readiness state: the operation exists structurally, but cannot participate until the variable refers to a non-nil channel.