Channels in Go
A channel is Go’s typed conduit for communicating values between concurrently executing goroutines — “Do not communicate by sharing memory; instead, share memory by communicating” (Go blog, Share Memory By Communicating). Every channel operation does double duty: it moves a value and it creates a happens-before edge, so a channel is simultaneously a queue and a synchronization primitive. A channel value (
chan T) carries an element type, an optional buffer capacity, and an optional direction; the three operations on it — sendch <- v, receive<-ch, andclose(ch)— have precisely specified semantics that, once internalized, explain nearly every “surprising” channel behavior.
This note is the language semantics of channels: send/receive/close rules, buffered versus unbuffered behavior, nil-channel behavior, direction, and how channels compose with select and range. Its sibling, Channel Internals, dissects the runtime hchan struct — the ring buffer, the sendq/recvq wait queues, the lock — that implements these semantics; this note deliberately does not re-explain that struct. The pseudo-random choice among ready channels belongs to The select Statement. The formal synchronization guarantees belong to the Go Memory Model.
Mental Model
flowchart LR subgraph UNBUF["Unbuffered chan (cap 0)"] direction TB S1["sender"] -. "rendezvous:<br/>both must be ready" .- R1["receiver"] end subgraph BUF["Buffered chan (cap N)"] direction TB S2["sender"] --> Q["[ slot | slot | ... ] FIFO"] Q --> R2["receiver"] end subgraph STATES["A channel is in one of three states"] O["open"] -->|"close()"| C["closed (one-way, permanent)"] NIL["nil (never made)"] end
Diagram: the two capacity regimes and the three lifecycle states. The insight: an unbuffered channel is a synchronization point — send and receive happen together, as a rendezvous, and neither completes until both are present. A buffered channel decouples them up to N items: a send completes as long as the buffer is not full, a receive as long as it is not empty. Orthogonally, every channel is nil, open, or closed — and each state changes what the three operations do.
Send, Receive, and Close — the Spec Rules
Channel types, capacity, and direction
A channel type is chan T, optionally directional: chan<- T is send-only, <-chan T is receive-only (Go spec, Channel types). The <- arrow associates with the leftmost chan. A channel is created with make, taking an optional capacity: “The capacity, in number of elements, sets the size of the buffer in the channel. If the capacity is zero or absent, the channel is unbuffered and communication succeeds only when both a sender and receiver are ready. Otherwise, the channel is buffered and communication succeeds without blocking if the buffer is not full (sends) or not empty (receives).” Channels are FIFO: “if one goroutine sends values on a channel and a second goroutine receives them, the values are received in the order sent.”
Direction is a static restriction enforced by the type system, not a runtime property — a bidirectional chan T is assignable to chan<- T or <-chan T, but not vice versa. Restricting direction at API boundaries documents and enforces ownership: a function taking chan<- T cannot accidentally receive (see Channel Direction and Ownership).
The send statement ch <- v
Per the spec (Send statements): “Both the channel and the value expression are evaluated before communication begins. Communication blocks until the send can proceed. A send on an unbuffered channel can proceed if a receiver is ready. A send on a buffered channel can proceed if there is room in the buffer. A send on a closed channel proceeds by causing a run-time panic. A send on a nil channel blocks forever.”
Four cases, then. Unbuffered: the send blocks until a receiver arrives, then both proceed together. Buffered with room: the value is copied into the buffer and the send returns immediately. Buffered full / unbuffered with no receiver: the send blocks. Closed: panic — panic: send on closed channel. Nil: blocks forever.
The receive operator <-ch
Per the spec (Receive operator): “The expression blocks until a value is available. Receiving from a nil channel blocks forever. A receive operation on a closed channel can always proceed immediately, yielding the element type’s zero value after any previously sent values have been received.”
The comma-ok form distinguishes a real value from a close-induced zero: “yields an additional untyped boolean result reporting whether the communication succeeded. The value of ok is true if the value received was delivered by a successful send operation to the channel, or false if it is a zero value generated because the channel is closed and empty.”
v := <-ch // value; v is the zero value if ch is closed and drained
v, ok := <-ch // ok == false iff ch is closed AND emptyThe crucial subtlety: a closed channel still delivers buffered values first. ok is true for every value that was sent before the close, and only becomes false once the buffer is drained. So a receiver can always drain a closed channel completely and then detect the close.
close(ch)
Per the spec (Close): close(ch) “records that no more values will be sent on the channel. It is an error if ch is a receive-only channel. Sending to or closing a closed channel causes a run-time panic. Closing the nil channel also causes a run-time panic. After calling close, and after any previously sent values have been received, receive operations will return the zero value for the channel’s type without blocking.”
So the close rules are: closing a <-chan T is a compile error; closing a nil channel panics; closing an already-closed channel panics; closing is permanent and one-way — there is no reopen. Close is a broadcast: it unblocks every goroutine currently or subsequently receiving (the runtime wakes every parked receiver when the close flag is set).
The Decision Table
The single most useful artifact for reasoning about channels is the full operation × state table.
| Operation | nil channel | open, not ready | open, ready | closed |
|---|---|---|---|---|
send ch <- v | blocks forever | blocks until ready | proceeds | panic |
receive <-ch | blocks forever | blocks until ready | proceeds | returns zero value, ok == false (after draining buffer) |
close close(ch) | panic | succeeds | succeeds | panic |
“Ready” means: for a send, a waiting receiver or buffer space; for a receive, a waiting sender or a buffered value. Almost every channel bug is a cell of this table the programmer did not expect. The two genuinely useful, non-obvious cells are send/receive on a nil channel blocks forever — which select exploits to “disable” a case — and receive on a closed channel never blocks — which makes for range ch a clean drain loop.
Buffered versus Unbuffered — Semantics and Synchronization
The difference is not merely “buffered is faster.” It changes the happens-before guarantee, and that is what matters for correctness.
An unbuffered channel is a rendezvous. The send and the corresponding receive complete at the same logical instant; the value passes directly from sender’s stack to receiver’s stack (the runtime does a direct stack-to-stack copy, bypassing any buffer). The Go Memory Model gives both directions: the send is synchronized-before the completion of the receive, and the receive is synchronized-before the completion of the send. So after an unbuffered exchange, each side knows the other reached the rendezvous. This makes unbuffered channels ideal for handoff with acknowledgement: when your send returns, you know the receiver took it.
A buffered channel decouples sender and receiver. A send into a non-full buffer returns without any receiver being present. The memory-model edge is one-directional and staggered: the k-th receive is synchronized-before the completion of the k+C-th send (where C is the capacity). The sender learns nothing about the receiver until the buffer fills. The staggered rule is exactly what makes a buffered channel a counting semaphore:
var limit = make(chan int, 3) // at most 3 concurrent workers
func worker(job func()) {
limit <- 1 // acquire: blocks once 3 tokens are out
job()
<-limit // release
}Three sends fit in the buffer; the fourth limit <- 1 blocks until some worker does <-limit.
The practical rule: choose unbuffered when you need synchronization or backpressure (the sender should wait for the receiver); choose buffered only with a deliberate capacity for a known reason — a semaphore count, a batch size, decoupling a bursty producer from a steady consumer. A buffer “to make it faster” with an arbitrary size is a classic mistake: it hides backpressure, delays the discovery of a stuck consumer, and changes the synchronization guarantee out from under you.
The Nil Channel — a Feature, Not Just a Trap
A nil channel — a chan T variable that was never maked — blocks forever on both send and receive, and panics on close. Naively this is a bug source (a forgotten make). But the “blocks forever” behavior is deliberately useful inside [[The select Statement|select]]: a select case whose channel is nil can never be chosen, so setting a channel variable to nil dynamically disables that case. This is the standard idiom for a state machine that should stop sending or stop receiving:
func merge(in <-chan int, done <-chan struct{}) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for {
select {
case v, ok := <-in:
if !ok {
in = nil // disable this case once `in` is drained
continue
}
out <- v
case <-done:
return
}
if in == nil {
return
}
}
}()
return out
}Once in is closed and drained, setting in = nil removes it from the select cleanly instead of spinning on the always-ready closed channel.
for range over a Channel
for v := range ch receives repeatedly until the channel is closed and drained, then the loop ends. It is the idiomatic consumer:
for v := range ch { // ends cleanly when ch is closed and empty
process(v)
}This composes with the close rule: the producer owns the channel and closes it when done; every consumer simply ranges. Ranging over a channel that is never closed leaks the consumer goroutine forever — see Goroutine Leak via Blocked Channel.
Failure Modes and Common Misunderstandings
Send on a closed channel panics. The hardest rule to design around: you cannot ask “is this channel closed?” before sending — there is no such query, and even if there were, the channel could close between the check and the send. The disciplined fix is the ownership convention: exactly one goroutine owns a channel and is the only one that sends and closes it. Closing is the sender’s signal “I am done”; receivers never close. When multiple senders exist, do not close the data channel from any of them — coordinate shutdown via a separate done/context channel instead.
Closing a channel twice panics. Two goroutines both deciding to close is a panic: close of closed channel. Same fix: single owner. Where unavoidable, gate the close with a [[sync.Once Internals|sync.Once]].
Forgetting to close. A for range consumer over a never-closed channel blocks forever — a goroutine leak. defer close(out) in the producer is the standard guard.
Nil-channel deadlock. A forgotten make gives a nil channel; the first send or receive blocks forever. If all goroutines block, the runtime detects it and reports fatal error: all goroutines are asleep - deadlock! — but a partial hang (one leaked goroutine) is silent.
Unbuffered channel deadlock in one goroutine. ch := make(chan int); ch <- 1 in main with no other goroutine deadlocks immediately — the send has no receiver and never will.
close does not “send a value.” Close is not a final message; it is a state change. Receivers see it as the ok == false result. If you need to send a sentinel value, send it explicitly before closing — but usually the zero-value-plus-ok is exactly what you want.
A receive after close is not “lost data.” Closed channels still hand out every buffered value first. Drain, then see ok == false.
Alternatives and When to Choose Them
Channels are not the only concurrency tool, and Go’s own guidance is to “use whichever is most expressive and/or most simple.” When the problem is passing ownership of data or signalling, a channel is the natural fit. When the problem is protecting a shared mutable structure with fine-grained, low-latency access, a [[sync.Mutex Internals|sync.Mutex]] is simpler and faster than funnelling every access through a channel and a server goroutine. For a one-shot signal “this is done,” a closed chan struct{} or a [[The Context Package|context.Context]] is idiomatic; for a count, a [[sync.WaitGroup Internals|WaitGroup]] or a buffered-channel semaphore. For a single shared counter, an atomic beats a channel by orders of magnitude. The anti-pattern is using a channel as a mutex (a chan struct{} of capacity 1) — it works but it is slower and obscures intent.
Production Notes
Channels are the backbone of Go’s structured concurrency patterns — pipelines, fan-in, worker pools — all of which are channels plus the ownership convention plus a done/context channel for cancellation (Go blog, Go Concurrency Patterns: Pipelines and cancellation). The single most common production bug is the leaked goroutine blocked on a channel: a producer that sends into a channel whose consumer has already returned, with no select on a cancellation channel to bail out. Go 1.26 adds an experimental goroutineleak profile in runtime/pprof precisely to surface goroutines wedged on channel operations (Go 1.26 Release Notes). The discipline that prevents these — one owner, close to signal done, always select on cancellation — is more important than any micro-optimization of buffer size.
See Also
- Channel Internals — the
hchanruntime struct that implements these semantics - The select Statement — choosing among multiple channel operations
- Go Memory Model — the formal happens-before guarantees of send/receive/close
- Buffered vs Unbuffered Channel Mistakes — the recurring buffer-sizing traps
- Goroutine Leak via Blocked Channel — the dominant channel-related leak
- Channel Direction and Ownership — the single-owner convention
- Pipeline Pattern · Fan-Out Fan-In Pattern · Worker Pool Pattern — channel-based structures
- The Context Package — the idiomatic cancellation signal
- Go Internals MOC — Section 9, Concurrency Internals