Declaring a channel
A channel is declared with the chan keyword followed by the type of values it carries:
func main() {
var ch chan string
fmt.Println(ch)
}
$ go run main.go
<nil>
Like maps and slices, the zero value of a channel is nil — that's exactly what gets printed here. A nil channel is declared but completely unusable: any send or receive on it blocks forever.
Careful with nil channels: sending on (or receiving from) a nil channel blocks forever. If every goroutine in your program ends up blocked this way, the runtime reports
fatal error: all goroutines are asleep - deadlock!. Note that this is a fatal error, not an ordinary panic — it cannot be caught withrecover(); the program simply dies.
To get a usable channel, initialize it with the built-in make function:
func main() {
ch := make(chan string)
fmt.Println(ch)
}
$ go run main.go
0x1400010e060
This time the output is a memory address (yours will differ), because — again like maps and slices — a channel is a reference to an underlying data structure created by make.
Sending and receiving
The channel operator <- does all the work, and the direction of the arrow tells you what's happening: ch <- value sends into the channel, and value := <-ch receives out of it.
func speak(arg string, ch chan string) {
ch <- arg // Send
}
func main() {
ch := make(chan string)
go speak("Hello World", ch)
data := <-ch // Receive
fmt.Println(data)
}
$ go run main.go
Hello World
Here main launches speak as a goroutine, then immediately tries to receive from ch. The receive blocks until the goroutine sends "Hello World" into the channel; once the value arrives, main prints it and exits. Notice there's no time.Sleep anywhere — we'll see exactly why this works reliably in the advanced section below.
Buffered channels
By default, a channel's capacity is 0 — it's unbuffered, meaning it can hold no values in transit and every send must be met by a receive. Passing a second argument to make gives the channel a buffer:
func main() {
ch := make(chan string, 2)
go speak("Hello World", ch)
go speak("Hi again", ch)
data1 := <-ch
fmt.Println(data1)
data2 := <-ch
fmt.Println(data2)
}
With a capacity of 2, both goroutines can deposit their strings into the buffer without waiting for anyone to pick them up. The rules are simple:
- A send blocks only when the buffer is full.
- A receive blocks only when the buffer is empty.
So a buffered channel decouples sender and receiver — up to the buffer's capacity. Once the buffer fills up, sends start blocking again, just like an unbuffered channel.
Directional channels
A channel variable can be restricted to sending only or receiving only, which is most useful in function signatures — it documents intent and lets the compiler enforce it:
func speak(arg string, ch chan<- string) {
ch <- arg // Send only
}
Here chan<- string declares a send-only channel: speak can push values in but can never read them back. The mirror image, <-chan string, declares a receive-only channel.
Closing a channel
A channel can be closed with the built-in close function, signalling that no more values will ever be sent on it. Receivers can detect this using the two-value ("comma ok") form of receive:
func main() {
ch := make(chan string, 2)
ch <- "Hello"
close(ch)
data1, ok := <-ch
fmt.Println(data1, ok)
data2, ok := <-ch
fmt.Println(data2, ok)
}
The first receive gets Hello true — there was still a buffered value, so ok is true. The second receive gets false — an empty string (the zero value) and ok == false, meaning the channel is closed and drained. That ok flag is the only reliable way to distinguish "someone sent me the zero value" from "the channel has nothing left to give."
Channel properties worth memorizing
These four rules explain nearly every channel bug you'll ever debug:
- A send to a nil channel blocks forever — resulting in a deadlock if nothing else can proceed.
- A receive from a nil channel blocks forever — same story.
- A send to a closed channel panics —
panic: send on closed channel. - A receive from a closed channel returns the zero value immediately — after the buffer is drained, and it never blocks.
Property 4 in action:
func main() {
var c = make(chan int, 2)
c <- 5
c <- 4
close(c)
for i := 0; i < 4; i++ {
fmt.Printf("%d ", <-c)
}
}
$ go run main.go
5 4 0 0
The first two receives drain the buffered values 5 and 4. The channel is closed, so the third and fourth receives don't block — they instantly return 0, the zero value for int. No panic, no error; if you didn't expect those zeros, this silent behavior can hide bugs, which is why the comma-ok form (or range) is usually preferable to blind receives.
Speaking of range — a for range loop over a channel receives values until the channel is closed and drained:
func main() {
ch := make(chan string, 2)
ch <- "Hello"
ch <- "World"
close(ch)
for data := range ch {
fmt.Println(data)
}
}
$ go run main.go
Hello
World
The loop pulls out Hello and World, then sees the channel is closed and exits cleanly. The close(ch) line is doing real work here — without it, the loop would keep waiting for a third value that never comes.
Advanced channel patterns
Unbuffered channels are synchronization rendezvous points. On an unbuffered channel, a send doesn't just transfer data — it blocks until a receiver is ready, and the handoff happens at that exact moment. This is why the speak goroutine example above works without any time.Sleep: main's receive and the goroutine's send synchronize with each other. Neither side can race ahead; the receive in main is itself the guarantee that the goroutine's message is not lost. Unbuffered channels therefore give you both communication and synchronization in one primitive.
Who should close a channel? Always the sender. Only the sender knows when there's nothing left to send, and a receiver that closes a channel risks causing a send on closed channel panic on the other side. Also remember that closing is optional — a channel doesn't leak if you never close it; you only need close when receivers must be told "that's all" (for example, to end a range loop). Two hard rules:
close(ch)
close(ch) // ❌ panic: close of closed channel
var nilCh chan int
close(nilCh) // ❌ panic: close of nil channel
range over a channel only terminates if the channel is closed. ⚠️ If no goroutine ever calls close, the loop blocks waiting for the next value, and once no other goroutine can send, the runtime kills the program with fatal error: all goroutines are asleep - deadlock!. Forgetting to close before ranging is one of the most common channel bugs.
Directional conversion is a one-way street. A bidirectional channel converts implicitly to a directional one — that's what happens when you pass make(chan string) to a parameter of type chan<- string. The reverse conversion is never allowed: once a channel is send-only or receive-only, you cannot get the other direction back. This makes directional parameters a genuine capability restriction, not just documentation.
Nil channels are actually useful — in select. Since operations on a nil channel block forever, setting a channel variable to nil inside a select loop disables that case without removing it from the code. It's a standard trick for turning off a data source once it's exhausted. (More on select in the next article.)
Buffered channels as semaphores. A buffered channel's blocking behavior makes it a natural counting semaphore for limiting concurrency:
sem := make(chan struct{}, 3) // at most 3 workers at once
for _, task := range tasks {
sem <- struct{}{} // acquire a slot (blocks if 3 are busy)
go func(t Task) {
defer func() { <-sem }() // release the slot
process(t)
}(task)
}
len and cap work on channels. len(ch) reports how many values are sitting in the buffer right now, and cap(ch) reports the buffer's capacity. They're handy for debugging and metrics — but don't build synchronization logic on len, because the answer can change between the moment you ask and the moment you act on it.
DUKE
Comments