AlgoMaster Logo

Nil Channels

Medium Priority14 min readUpdated June 6, 2026

A nil channel is what you get when you declare a channel variable but never call make on it. Every operation on it blocks forever, and closing it panics. The same property turns nil channels into a precise tool for switching cases on and off inside a select.

The Zero Value of a Channel Is Nil

A channel in Go is a reference type. Like maps and slices, its zero value is nil, and the only way to get a usable channel is to call make. Forgetting that one call is the source of most accidental nil-channel bugs.

The declaration var orders chan string reserves a channel-typed variable but doesn't allocate the underlying channel buffer or any of the runtime machinery that makes sends and receives work. Until something is assigned to it (usually the result of make), it stays nil.

Three places this happens in real code:

  • A struct field of channel type that nobody initialized in the constructor.
  • A package-level var that the program forgot to set up in init.
  • A return path in a factory function that bails out early without setting the channel.

The compiler doesn't help here. var orders chan string is a valid program, and so is reading from orders or writing to it. The trouble only shows up at runtime, when the goroutine touching the nil channel goes to sleep and never wakes up.

Sends and Receives on a Nil Channel Block Forever

The rule is simple and consistent: any send or receive on a nil channel blocks forever. The runtime doesn't panic, doesn't return an error, doesn't print a warning. The goroutine just parks itself and never gets rescheduled.

Here's a minimal program that demonstrates a receive on a nil channel:

The Go runtime has a deadlock detector that fires when every goroutine is parked with no chance of being woken. Since this program has exactly one goroutine and it's blocked on a nil channel receive, the runtime knows the program can't make progress and prints fatal error: all goroutines are asleep. The stack trace pinpoints the line and includes [chan receive (nil chan)], which is the runtime telling you exactly why it's stuck.

A send on a nil channel behaves the same way:

Same deadlock, different label in the stack trace (chan send (nil chan)). When you see one of these traces, the bug is almost always a missing make.

The deadlock detector only fires when every goroutine in the program is stuck. If even one goroutine is making progress (sleeping on a timer, doing CPU work, waiting on I/O), the nil-channel block is invisible. The goroutine sitting on the nil channel quietly waits forever while the rest of the program runs normally. That's the silent leak case covered later in the lesson.

Closing a Nil Channel Panics

Sends and receives on nil channels block. Closing one is the only operation that fails loudly:

The panic message is exact: close of nil channel. This is one of the few channel mistakes that crashes the program instead of hanging it, which is actually helpful. A panic with a clear message is much easier to debug than a deadlock with no obvious cause.

The panic happens whether the program has other live goroutines or not. Unlike the blocking deadlock case, this one doesn't wait for the deadlock detector. The runtime checks for nil inside close and panics immediately.

Think of close as a write operation that touches the channel's state directly. A nil channel has no state to touch, so the runtime treats this as a programming error and refuses to continue. The same logic applies if you close an already-closed channel (panic: close of closed channel) and if you send on a closed channel (panic: send on closed channel). The send-or-receive blocking on nil is the odd one out, behavior-wise.

The Channel Operation Matrix

Putting all of it in one place makes the pattern click:

OperationNil channelOpen channelClosed channel
<-ch (receive)Blocks foreverReceives a value, or blocks if emptyReturns zero value immediately, ok is false
ch <- v (send)Blocks foreverSends a value, or blocks if buffer is fullPanics: send on closed channel
close(ch)Panics: close of nil channelMarks channel closedPanics: close of closed channel
len(ch) / cap(ch)Returns 0Returns current/buffer sizeReturns current/buffer size

The blocking-forever row is what makes nil channels strange at first glance. It looks like a bug masquerading as a feature. But the next section is where the design pays off.

The Useful Part: Disabling a Case in select

select picks one of its ready cases at random and executes it. A case whose channel is nil is permanently not ready, so select skips over it as long as nil. That gives you a clean way to turn a case off without touching any control flow around the select.

Here's the canonical pattern. Two pipelines feed orders into a processor. One produces new orders, the other produces refund requests. When a pipeline finishes (its channel closes), we want the consumer to keep draining the other one. The trick is to nil out the closed channel so select stops trying to read from it.

The shape of the loop is what to focus on. Without the nil trick, once refunds closes, <-refunds returns the zero value immediately, every time. The select would spin, picking the always-ready closed channel over and over, ignoring the orders that are still arriving. With the nil trick, the closed channel becomes invisible to select, and the loop switches to a single-channel drain of newOrders until that one closes too. When both are nil, the outer for condition (newOrders != nil || refunds != nil) flips false and the loop exits.

Three details:

  • The continue after setting the channel to nil isn't strictly required (the next loop iteration would do the same thing), but it makes the intent obvious.
  • Setting the channel variable to nil doesn't close the channel or affect any other goroutine that holds a reference. It only affects this particular select.
  • The loop condition reads cleanly: "keep going as long as at least one pipeline is still active". That's the entire termination logic in one line.

The select state machine looks like this:

A case starts in the "Active" state, where it can fire whenever its channel has a value. When the channel closes and the consumer sees ok == false, the case moves to "Drained". Setting that case's channel variable to nil moves it to "Disabled", and from there select ignores it forever (or until the variable is reassigned to a real channel, which is rare).

Alternating Between Available Pipelines

The disable-a-case trick generalizes. You can flip channels between nil and non-nil to turn cases on and off as the program's state changes. A useful variant is gating a sender: a producer with output ready to publish, but a downstream channel that should only accept values when the consumer is keeping up.

A worker reads incoming orders, processes them, and tries to publish results. If the result channel is busy (buffer full), the worker should keep reading new orders instead of blocking on the publish. We model "result channel available" by either holding a real channel or holding nil.

The worker uses sendCh as a gate. When there's nothing to send, sendCh is nil and the send case is disabled, so the select only reads from in. As soon as the worker has a pending value, it assigns sendCh = out, which arms the send case. The next time select runs, both cases are eligible, and Go picks whichever is ready (the send if out has buffer space, the receive if a new order is waiting). After the send completes, sendCh goes back to nil and the cycle resumes.

This is more flexible than splitting the logic into multiple goroutines or using a default case. There's no busy-waiting, no priority inversion, and the worker naturally backpressures when the consumer is slow.

A common variation of this pattern uses nil to disable a timeout case after the work finishes, or to alternate which of several inputs is allowed to fire. The structural rule is the same: a case stays in the select body, but its channel toggles between a real value and nil to control whether it can run.

The Cautionary Case: Silent Deadlocks

The same property that makes nil channels useful makes accidental nil channels devastating. A goroutine blocked on a nil channel doesn't crash, doesn't log, doesn't time out. It just stops. If other goroutines are still running, the deadlock detector won't even fire.

The most common cause is a missing make in a constructor. A code reviewer scanning the struct definition sees notify chan string and assumes it's wired up. Six months later, a path through the code reads from notify (or writes to it) and hangs without any error.

Submit and Drain are both parked on the nil incoming channel, but main is busy with the ticker loop, so the runtime sees forward progress and the deadlock detector stays quiet. The "submitted 101" line never prints. The orders silently vanish. In a real service, the symptom would be "the queue accepts submissions but nothing ever processes them", with no error in any log.

The fix is to initialize the channel in the constructor:

That's it. Once incoming is a real channel, both Submit and the range in Drain work correctly.

A second common cause is a function that returns a chan T but takes an early return path that yields the zero value:

A caller that doesn't check for nil and just does for v := range openOrderStream(false) will hang silently. What to do depends on the function's contract:

  • If "not connected" is an error, return (chan int, error) and force the caller to handle it.
  • If "no stream available" is fine, return an already-closed channel, which yields zero receives and ends the range immediately.

Now range openOrderStream(false) terminates instantly. Same surface API, no nil-channel trap.

Reading the Pattern in Existing Code

When reading Go code, two shapes signal intentional nil-channel use:

  • A local variable of channel type that is assigned to either nil or a real channel inside a select body. This is the disable-a-case pattern. The variable name often matches the corresponding case's purpose (sendCh, readyCh, out).
  • A for loop whose condition is chA != nil || chB != nil. This is the multi-pipeline drain pattern. The body always nils out a channel after seeing ok == false on it.

Any other nil channel in production code is suspect. A struct field that's nil because the constructor was incomplete, a return path that yields nil for a channel under some condition, a global that was never initialized, all of these are bugs in waiting. The intentional pattern uses nil locally inside one function, not as a value flowing through the program.

Quiz

Nil Channels Quiz

10 quizzes