Nil channel usage

Initialize channels with make() to prevent indefinite blocking caused by nil channel operations.

The silent hang

You write a concurrent function. You declare a channel, spawn a goroutine to do some work, and try to send the result back. The program compiles cleanly. You run it. Nothing happens. The terminal just sits there, waiting. You check your goroutine. It is running. You check the channel. It is declared. Yet the send operation never completes. The culprit is almost certainly a nil channel.

What a nil channel actually is

In Go, every variable gets a zero value the moment you declare it. For integers, that is zero. For strings, that is an empty string. For channels, the zero value is nil. A nil channel is not a closed channel. It is not a channel with zero capacity. It is a channel that does not exist yet.

Think of a channel as a pipe connecting two rooms. A nil channel is like drawing a blueprint for a pipe but never actually laying the plumbing. If you try to push water through a pipe that was never installed, the pressure builds up and nothing moves. In Go, that pressure is a goroutine waiting forever.

Sending to or receiving from a nil channel blocks indefinitely. The runtime parks the goroutine and waits for the channel to become ready. Since a nil channel can never become ready, the goroutine sleeps until the program exits. This is by design. The Go team made nil channels block so that uninitialized channels fail loudly rather than silently dropping data or returning zero values.

The minimal trap

Here is the simplest way to trigger the hang.

package main

import "fmt"

func main() {
	// Declaring without make leaves the channel at its zero value: nil
	var ch chan string

	// This send blocks forever because the underlying channel structure does not exist
	ch <- "hello"

	// The scheduler never returns to this line
	fmt.Println("This line never prints")
}

Run this and watch it freeze. The Go runtime is kind enough to notice when every goroutine in the program is stuck waiting on a channel. After a brief pause, it aborts with fatal error: all goroutines are asleep - deadlock!. That panic is the runtime's way of telling you that your concurrency graph has no exit path.

The fix is straightforward. Channels are reference types that require heap allocation and internal bookkeeping. You must allocate them with make.

package main

import "fmt"

func main() {
	// make allocates the hchan struct, sets up wait queues, and returns a valid reference
	ch := make(chan string)

	// Spawn a goroutine to send so the main function can receive without deadlocking
	go func() {
		ch <- "hello"
	}()

	// Receiving from a valid unbuffered channel blocks until a matching send arrives
	msg := <-ch
	fmt.Println(msg)
}

The program prints hello and exits cleanly. make sets up the internal queue, the mutex, and the wait lists that the scheduler uses to coordinate goroutines. Without it, you are just holding a null pointer.

Always initialize channels with make. A declared channel is just a placeholder.

How the scheduler handles it

Under the hood, a Go channel is a struct called hchan. It contains a circular buffer for buffered channels, a count of elements, a mutex for synchronization, and two linked lists of waiting goroutines. One list tracks goroutines waiting to send. The other tracks goroutines waiting to receive.

When you call make(chan T), the runtime allocates this struct on the heap and returns a pointer to it. When a goroutine executes ch <- value, the runtime checks the channel pointer. If it is valid, the runtime acquires the mutex, checks if the buffer has space, and either stores the value or parks the goroutine on the send wait list. If the pointer is nil, the runtime skips all of that logic and immediately parks the goroutine on a special internal wait list for nil channels. That wait list has no corresponding wake-up mechanism. The goroutine stays there until the process terminates.

This behavior extends to the select statement. The compiler translates select into a state machine that evaluates each case. If a case involves a nil channel, the compiler marks that case as permanently unreadable or unwritable. The scheduler simply removes it from contention. This is why nil channels are safe to use in select without causing panics. They are treated as disabled paths, not as broken pipes.

Trust the scheduler. It will not crash on a nil channel in a select. It will just ignore it.

The intentional use case

Nil channels are not just a trap for beginners. They are a deliberate design feature in Go's select statement. When a case in a select uses a nil channel, that case is automatically disabled. The scheduler ignores it entirely. This makes nil channels perfect for toggling behavior at runtime without rewriting control flow.

Imagine a background worker that can receive tasks from two different sources. You want to enable or disable each source dynamically based on configuration. Instead of writing complex if statements around every select, you just swap the channel references.

package main

import (
	"fmt"
	"time"
)

// Worker processes tasks from either or both channels
func Worker(primary chan string, secondary chan string) {
	for {
		select {
		// If primary is nil, the scheduler skips this case entirely
		case msg := <-primary:
			fmt.Println("Primary:", msg)
		// If secondary is nil, the scheduler skips this case entirely
		case msg := <-secondary:
			fmt.Println("Secondary:", msg)
		// Default case prevents blocking when both channels are empty
		default:
			time.Sleep(10 * time.Millisecond)
		}
	}
}

You can control the worker by passing actual channels or nil.

func main() {
	// Only primary is active. Secondary is nil, so the select ignores it.
	primary := make(chan string)
	var secondary chan string // nil

	go Worker(primary, secondary)

	primary <- "task A"
	time.Sleep(100 * time.Millisecond)
}

This pattern scales cleanly. You can build fan-in structures, toggle logging streams, or gate feature flags by simply assigning nil to a channel variable. The select statement handles the routing logic for you. The convention in the Go community is to name these toggle channels explicitly, like taskCh or signalCh, and document that nil disables the stream.

Nil channels are switches, not pipes. Use them to disable select cases, not to carry data.

Pitfalls and runtime behavior

The most common mistake is confusing a nil channel with a closed channel. Closing a channel signals that no more values will be sent. Receiving from a closed channel returns the zero value immediately and sets the second return value to false. Sending to a closed channel panics. A nil channel does none of this. It just waits.

Another frequent confusion involves non-blocking operations. Some developers try to use a nil channel to achieve immediate returns, assuming it will fail fast. It does not. It blocks. If you need a send or receive that does not block, you have two correct options. Use a buffered channel with enough capacity to absorb the message without waiting. Or use a select statement with a default case. The default case runs immediately if no other case is ready, giving you true non-blocking behavior.

The compiler will not save you here. Go treats channels as reference types, and declaring a variable without initialization is perfectly legal syntax. You will only catch the problem at runtime. The fatal error: all goroutines are asleep - deadlock! panic is your only warning. If your program has other goroutines running, like an HTTP server or a ticker, the deadlock panic might never trigger. The nil channel will just sit there, holding a goroutine hostage forever. That is a goroutine leak. The leaked goroutine consumes stack space and holds onto any resources it captured. Over time, memory usage climbs until the process crashes.

The convention for avoiding this is simple. If a channel is not ready yet, set it to nil intentionally and document why. If a channel is meant to be used immediately, initialize it with make at the declaration site. Do not defer channel creation to a later function call unless you are explicitly building a toggle pattern.

Never leave a channel uninitialized. If a channel is not ready yet, set it to nil intentionally and document why.

When to reach for what

Use a nil channel when you want to disable a case in a select statement without changing the surrounding code. Use make(chan T) when you need a standard unbuffered channel for synchronization or point-to-point communication. Use make(chan T, n) when you need a buffered channel to decouple senders and receivers or to prevent blocking under load. Use a select with a default case when you need a non-blocking send or receive that returns immediately if the channel is busy. Use plain sequential code when you do not actually need concurrency. The simplest thing that works is usually the right thing.

Pick the channel type that matches your synchronization needs. Do not guess.

Where to go next