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.
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:
var that the program forgot to set up in init.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.
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.
Cost: A goroutine parked on a nil channel holds its stack (about 2 KB initially) and a scheduler slot. One is harmless. A server that leaks one per request runs out of memory eventually.
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.
Putting all of it in one place makes the pattern click:
| Operation | Nil channel | Open channel | Closed channel |
|---|---|---|---|
<-ch (receive) | Blocks forever | Receives a value, or blocks if empty | Returns zero value immediately, ok is false |
ch <- v (send) | Blocks forever | Sends a value, or blocks if buffer is full | Panics: send on closed channel |
close(ch) | Panics: close of nil channel | Marks channel closed | Panics: close of closed channel |
len(ch) / cap(ch) | Returns 0 | Returns current/buffer size | Returns 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.
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:
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.select.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).
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.
Cost: Each nil-toggle is just a variable assignment. There's no runtime allocation and no scheduling cost. The select runtime walks its cases once per fire and skips any whose channel is nil.
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 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:
(chan int, error) and force the caller to handle it.range immediately.Now range openOrderStream(false) terminates instantly. Same surface API, no nil-channel trap.
When reading Go code, two shapes signal intentional nil-channel use:
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).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.
Cost: select with nil cases doesn't pay any extra runtime cost compared to a regular select. The runtime evaluates each case's readiness; a nil channel is reported as "not ready" and skipped. No allocations, no syscalls, no extra goroutine wake-ups.
10 quizzes