Channels
What is a channel?
Section titled “What is a channel?”A channel is a typed conduit through which goroutines communicate. Sending a value into a channel and receiving from it are the two operations that transfer both data and synchronization in a single step.
ch := make(chan int) // unbuffered channel of intch <- 42 // send (blocks until a receiver is ready)v := <-ch // receive (blocks until a sender is ready)The type system enforces what flows through a channel. A chan string will not accept an int.
Unbuffered vs buffered
Section titled “Unbuffered vs buffered”| Unbuffered | Buffered | |
|---|---|---|
| Created with | make(chan T) | make(chan T, capacity) |
| Send blocks? | Yes — until a receiver is ready | No — until the buffer is full |
| Receive blocks? | Yes — until a sender is ready | No — until the buffer is empty |
| Synchronizes? | Yes — sender and receiver meet at the same moment | No — the buffer decouples timing |
Unbuffered channels provide the strongest synchronization guarantee: the sender knows the receiver has the value before it continues.
Channel directions
Section titled “Channel directions”You can restrict a channel parameter to send-only or receive-only to encode the intent at the type level:
func producer(out chan<- int) { out <- 1 } // send-onlyfunc consumer(in <-chan int) { v := <-in; _ = v } // receive-onlyA bidirectional chan T is assignable to either direction. The compiler enforces that a receive-only channel cannot be sent to, and vice versa.
Closing a channel and ranging over it
Section titled “Closing a channel and ranging over it”The sender calls close(ch) to signal that no more values will be sent. Receivers can use a for range loop to drain all values until close:
close(ch)for v := range ch { // exits when ch is closed and empty fmt.Println(v)}The comma-ok receive idiom
Section titled “The comma-ok receive idiom”A receive expression can return a second boolean that tells you whether the channel is still open:
v, ok := <-ch// ok == true → v is a real value// ok == false → channel is closed and drained; v is the zero valueNil channels
Section titled “Nil channels”A channel variable that is only declared, never make’d, is nil:
var ch chan int // nil — not the same as make(chan int)Every operation on a nil channel blocks forever — not panics, blocks. Both a send and a receive on a nil channel park the goroutine with nothing that could ever wake it:
var ch chan int<-ch // blocks foreverRun as the only goroutine in main, this is a real deadlock: a state where a set of goroutines are all blocked, each waiting on something that only another blocked goroutine in the same set could provide, so none of them can ever make progress. The Go runtime detects the specific case where every goroutine in the program is blocked and terminates with a fatal error, not a panic — it cannot be recovered with recover():
fatal error: all goroutines are asleep - deadlock!
goroutine 1 [chan receive (nil chan)]:main.main() /tmp/nilchandeadlock/main.go:5 +0x2cexit status 2The [chan receive (nil chan)] tag in the goroutine trace is the tell — it names the nil channel as the cause, not just “blocked”. A nil channel send deadlocks identically, reported as [chan send (nil chan)]. This is why a zero-value chan field that a struct forgot to initialize is a classic AI-generated bug: it compiles, and it hangs the first time anything touches it.
Nil channels are not purely a bug, though — a nil case in a select is disabled rather than blocking, which is a deliberate technique covered in the Select lesson.
The complete channel operation table
Section titled “The complete channel operation table”The three operations — send, receive, close — behave differently depending on whether the channel is nil, open, or closed:
| Operation | nil channel | open channel | closed channel |
|---|---|---|---|
ch <- v (send) | blocks forever | blocks until a receiver is ready (unbuffered) or the buffer has room | panics: send on closed channel |
<-ch (receive) | blocks forever | blocks until a value is available | returns immediately: zero value, ok == false |
close(ch) | panics: close of nil channel | closes it — any blocked receivers unblock immediately | panics: close of closed channel |
len(ch) | 0 | number of buffered elements not yet received | number of buffered elements still unreceived |
cap(ch) | 0 | the buffer capacity | the buffer capacity (unchanged by closing) |
All three panic messages and the deadlock message above are copied verbatim from real go run output on Go 1.26.5. The pattern worth memorizing: nil blocks, closed panics on send and never blocks on receive.
package main
import "fmt"
func producer(ch chan<- int, n int) { for i := 1; i <= n; i++ { ch <- i } close(ch)}
func main() { // Unbuffered channel: producer runs in a goroutine ch := make(chan int) go producer(ch, 5) for v := range ch { fmt.Println(v) }
// Buffered channel: all sends fit in the buffer buf := make(chan string, 3) buf <- "a" buf <- "b" buf <- "c" close(buf) for s := range buf { fmt.Println(s) }
// Comma-ok: distinguish real value from closed zero value done := make(chan bool, 1) done <- true close(done) v1, ok1 := <-done fmt.Println(v1, ok1) // true true v2, ok2 := <-done fmt.Println(v2, ok2) // false false} close as a broadcast signal
Section titled “close as a broadcast signal”Closing a channel wakes up every goroutine currently blocked receiving on it, not just one — this is different from a normal receive, which only ever delivers a value to a single waiting goroutine. Combined with chan struct{}, this makes close the idiomatic way to broadcast “stop” or “ready” to an unknown number of goroutines at once.
struct{} is the empty struct — it has no fields and occupies zero bytes. A chan struct{} is used purely as a signal; nobody cares about the value received, only about the fact that the receive unblocked:
done := make(chan struct{})
go func() { <-done // unblocks the instant done is closed fmt.Println("goroutine 1 saw the signal")}()go func() { <-done // unblocks at the same instant, independently fmt.Println("goroutine 2 saw the signal")}()
close(done) // broadcasts to both goroutines simultaneouslySending a single value on done would only wake one of the two goroutines — the other would block forever. close is the only channel operation that reaches every waiter at once.
Happens-before and channel ownership
Section titled “Happens-before and channel ownership”Channels are not just a scheduling convenience — they are part of the Go memory model’s guarantee about when writes made in one goroutine become visible in another. The rule: a send on a channel happens-before the corresponding receive completes, and for a channel with capacity C, the k-th send happens-before the (k+C)-th receive completes. In practice this means any variable a goroutine wrote before it sent on a channel is guaranteed to be visible to whatever goroutine reads it after receiving from that channel — no separate mutex or atomic needed for that specific handoff.
This guarantee is also why Go channels have a clear ownership rule: whichever goroutine creates a channel is the one that sends on it and closes it; every other goroutine only ever holds a receive-only <-chan T and never closes or sends. A function that returns a channel should return the directional type to make this enforceable by the compiler, not just by convention:
// producer owns out: it is the only goroutine that sends to it or closes it.func producer(n int) <-chan int { out := make(chan int, n) go func() { defer close(out) // the owner closes, always for i := 1; i <= n; i++ { out <- i } }() return out // callers only ever see <-chan int}Violating this — for example, two goroutines that both think they own the same channel and both call close — is exactly the bug in this lesson’s “Reading AI-generated code” section below.
Buffered channels as a semaphore
Section titled “Buffered channels as a semaphore”A buffered channel’s capacity can be used purely for counting, not for carrying data — this is the classic Go implementation of a counting semaphore. Acquire a permit by sending into the channel (blocks once the buffer is full); release it by receiving:
sem := make(chan struct{}, 2) // at most 2 permits
sem <- struct{}{} // acquire — blocks if 2 permits are already out// ... critical section, at most 2 goroutines here at once ...<-sem // releaseThis is the simplest form of the bounded-parallelism pattern covered in full in the Patterns lesson — worth recognising here because it is a second, very different use of a buffered channel’s capacity beyond just “smoothing out timing.”
The snippet below runs all three ideas: close as a broadcast to three waiting goroutines, a chan struct{} buffered to 2 used as a semaphore, and a producer that owns its channel end to end.
package main
import ( "fmt" "sort" "sync")
func main() { // close as broadcast: every receiver unblocks the instant the channel // closes, no matter how many goroutines are waiting. done := make(chan struct{}) var wg sync.WaitGroup results := make([]string, 3) for i := 0; i < 3; i++ { wg.Add(1) go func(id int) { defer wg.Done() <-done // unblocks for every goroutine at once when done is closed results[id] = fmt.Sprintf("worker %d saw the broadcast", id) }(i) } close(done) // broadcasts to all 3 waiters simultaneously wg.Wait() for _, r := range results { fmt.Println(r) }
// buffered channel as a counting semaphore: only 2 permits, so at most 2 // goroutines run their critical section concurrently. sem := make(chan struct{}, 2) var mu sync.Mutex inFlight, maxInFlight := 0, 0 var wg2 sync.WaitGroup for i := 0; i < 5; i++ { wg2.Add(1) go func() { defer wg2.Done() sem <- struct{}{} // acquire mu.Lock() inFlight++ if inFlight > maxInFlight { maxInFlight = inFlight } mu.Unlock()
mu.Lock() inFlight-- mu.Unlock() <-sem // release }() } wg2.Wait() fmt.Println("never exceeded 2 concurrent holders:", maxInFlight <= 2)
// ownership: the sender both sends and closes; the consumer only ever // holds a <-chan so it physically cannot close or send. ch := ownedProducer(3) var got []int for v := range ch { got = append(got, v) } sort.Ints(got) fmt.Println(got)}
// ownedProducer owns out: it is the only goroutine that sends to it or// closes it. Callers only ever see the receive-only <-chan int.func ownedProducer(n int) <-chan int { out := make(chan int, n) go func() { defer close(out) for i := 1; i <= n; i++ { out <- i } }() return out} Mental Model
Section titled “Mental Model”flowchart TD
subgraph Unbuf["Unbuffered channel -- rendez-vous point"]
G1u["G1"] -->|"send (blocks)"| CHu["channel"]
G2u["G2"] -->|"recv (blocks)"| CHu
CHu --> Meet["G1 and G2 must meet"]
end
subgraph Buf["Buffered channel (cap=3)"]
G1b["G1"] -->|"send"| Slots["[_][_][_]"]
Slots -->|"recv"| G2b["G2"]
Slots -.->|"blocks when full"| G1b
Slots -.->|"blocks when empty"| G2b
Slots --> Indep["G1/G2 run more independently"]
end flowchart LR
subgraph Lifecycle["chan T lifecycle"]
Nil["nil
(var ch chan T)"]
Open["open
(make(chan T, n))"]
Closed["closed
(after close(ch))"]
end
Nil -->|make chan T| Open
Open -->|close ch| Closed
Nil -.send / receive: blocks forever.-> Nil
Closed -.send: panic!.-> Closed
Closed -.receive: zero value, ok=false, never blocks.-> Closed Trade-offs
Section titled “Trade-offs”| What you get | Benefit | Cost |
|---|---|---|
| unbuffered channel | strongest synchronization guarantee | sending G blocks until a receiving G is ready |
| buffered channel | decouples sender/receiver timing | must size the buffer correctly, can hide backpressure |
channel direction (chan<-, <-chan) | compile-time safety | more verbose function signatures |
| close + range | easy completion notification | sending after close panics |
Common Misconceptions
Section titled “Common Misconceptions”- Channels are always faster than mutexes — a channel has more overhead than a mutex for a simple counter
- Buffered channels prevent deadlock — not always; deadlock can still happen if the buffer is full and there is no receiver
close(ch)discards data in the buffer — no; a receiver can still drain the remaining buffered values after close- The receiver can close the channel — only the sender should close a channel
Reading AI-generated code
Section titled “Reading AI-generated code”When a generated snippet creates or closes a channel, check for these before trusting it:
- Is there exactly one goroutine that ever calls
closeon this channel, or could two goroutines both reach aclosecall on the same one? - Does every goroutine that sends on this channel have a receiver that is guaranteed to still be reading — or could the receiver have already returned, leaving the sender blocked forever?
- Is a channel variable used before it is
make’d (a nil channel field on a zero-value struct, or avar ch chan Tthat was never assigned)? Every operation on it blocks forever instead of panicking. - Does a function that only ever sends declare its channel parameter as bidirectional (
chan T) instead ofchan<- T? Not a bug, but a missed compile-time guardrail against an accidental receive. - On every path — including error returns — is a send-after-close actually impossible, or does an early-return branch skip past a point where the channel is already closed?
🐛 Spot the bug
func merge(chs ...<-chan int) <-chan int { out := make(chan int) var wg sync.WaitGroup for _, c := range chs { wg.Add(1) go func(c <-chan int) { defer wg.Done() for v := range c { out <- v } close(out) // called once per input channel }(c) } return out}Show the answer
Every one of the len(chs) goroutines calls close(out) when its own input channel drains — but out is shared. The first goroutine to finish closes it; every other goroutine that finishes afterward panics on its own close(out) call, and any goroutine still trying to send on it panics with send on closed channel. The real output on Go 1.26.5 (order varies by run):
panic: send on closed channel
goroutine 19 [running]:main.merge.func1(0xc0001c8000) /tmp/mergebug/main.go:16 +0x98go run -race additionally reports a WARNING: DATA RACE between the racing send and the receive that triggers close — this is exactly the “who closes” ownership question from earlier in this lesson. The fix: nobody closes out inside the per-channel goroutines at all. A single dedicated goroutine calls wg.Wait() and then close(out) exactly once, after every input goroutine has finished — see the fan-in merge in the Patterns lesson for the corrected version.