Channels: Buffered, Unbuffered & Closing Rules
Channels carry values and synchronization between goroutines.
Choosing buffered versus unbuffered capacity and following close ownership rules prevents deadlocks and panics in production pipelines.
Summary
An unbuffered channel synchronizes partners: the sender blocks until the receiver is ready.
A buffered channel stores up to cap values without a waiting receiver.
Closing a channel broadcasts "no more sends" to receivers; only senders should close.
range over a channel drains until close; the two-value receive v, ok := <-ch detects close explicitly.
Recipe
Quick-reference recipe card - copy-paste ready.
package main
import "fmt"
func main() {
ch := make(chan int, 2) // buffered
ch <- 1
ch <- 2
close(ch)
for v := range ch {
fmt.Println(v)
}
v, ok := <-ch
fmt.Println(v, ok) // 0 false after drained close
}When to reach for this:
- Unbuffered: handoff, ack, strict backpressure between stages.
- Buffered: smooth bursts when producer and consumer run at different speeds.
- Close: producer finished; consumers should exit
rangeloops. - Never close from the receiver side in shared pipelines.
Working Example
package main
import (
"fmt"
"sync"
)
func producer(out chan<- int, wg *sync.WaitGroup) {
defer wg.Done()
for i := 1; i <= 5; i++ {
out <- i
}
close(out) // sender closes
}
func consumer(in <-chan int, wg *sync.WaitGroup) {
defer wg.Done()
for v := range in {
fmt.Println("got", v)
}
}
func main() {
ch := make(chan int) // unbuffered - sync handoff
var wg sync.WaitGroup
wg.Add(2)
go producer(ch, &wg)
go consumer(ch, &wg)
wg.Wait()
}What this demonstrates:
- Directional types
chan<-and<-chandocument send-only/receive-only usage. - Sender closes after last send so
rangein consumer terminates. - Unbuffered channel couples producer and consumer timing.
WaitGroupwaits for both sides to finish.
Deep Dive
How It Works
- Channel send copies the value into the channel buffer or hands off directly (unbuffered).
- Receive copies out and frees a buffer slot or unblocks a sender.
close(ch)sets closed flag; future receives get zero value withok == false.- Send on closed channel panics; receive on closed channel is safe (returns zero).
len(ch)andcap(ch)report queued elements and capacity.
Buffered vs Unbuffered
| Type | Send blocks when | Receive blocks when | Sync strength |
|---|---|---|---|
| Unbuffered | No receiver ready | No sender ready | Strong (rendezvous) |
| Buffered | Buffer full | Buffer empty | Weaker until full |
Close Rules
| Rule | Rationale |
|---|---|
| Sender closes | Receivers should not signal producer completion |
| Close once | Double close panics |
Receivers detect via ok or range | Clean shutdown of consumer loops |
| Do not close work channels early | Consumers may still expect values |
Go Notes
// Fan-in: one goroutine closes after all senders done
func fanIn(inputs ...<-chan int) <-chan int {
out := make(chan int)
var wg sync.WaitGroup
wg.Add(len(inputs))
for _, in := range inputs {
go func(c <-chan int) {
defer wg.Done()
for v := range c {
out <- v
}
}(in)
}
go func() {
wg.Wait()
close(out)
}()
return out
}Gotchas
- Send on closed channel - Panic. Fix: only sender closes; use
sync.Onceto guard close. - Leak: receiver gone, sender blocks - Unbuffered send waits forever. Fix:
contextcancel, timeoutselect, or buffer. - Range without close - Consumer blocks forever. Fix: sender must
closeor cancel upstream. - Closing consumer-facing channel while sending - Race with in-flight sends. Fix: close only after all sends complete (often
WaitGroupthen close). - Huge buffer masking overload - Memory grows, latency hides. Fix: size to SLA; shed load when full.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
| Unbuffered channel | Need strict handoff | High throughput with mismatched rates |
| Buffered channel | Burst absorption | You need immediate backpressure signals |
sync.Mutex + slice queue | Shared in-memory queue | Cross-goroutine ownership transfer is clearer with channels |
context only | Cancellation signaling | Passing work items (use channels) |
FAQs
How do I pick buffer size?
Start with 0 (unbuffered) or a small multiple of worker count.
Benchmark and watch queue depth under load; oversized buffers hide overload.
Can multiple goroutines send on one channel?
Yes - sends are safe concurrently.
Coordinate close so only one goroutine closes after all senders finish.
What does receiving from a nil channel do?
Blocks forever.
Use a nil channel in select to disable a case dynamically.
Is close required for garbage collection?
Receivers blocked forever keep senders alive regardless of close.
Close is for semantics and terminating range, not primarily GC.
Can I close a channel from the receiver?
Technically possible in toy examples; in pipelines it breaks ownership.
Receivers should exit on close, not initiate it.
What is the zero value of a channel?
nil - operations on nil channels block forever (useful in select tricks).
How do directional channel types help?
chan<- T and <-chan T in function signatures document intent and prevent accidental receive/send at compile time.
Does range copy values out?
Yes - each receive copies.
Send pointers if mutating shared structs (with synchronization).
What happens with buffered close and leftover values?
Receivers drain buffered values after close, then get ok == false.
How do channels relate to happens-before?
A send on a channel happens-before the corresponding receive completes.
That edge is part of Go's memory model.
Related
- Concurrency Basics - first channel examples
- select Statement & Default Cases - multiplex receives
- Concurrency in Go: Goroutines and Channels First - CSP overview
- sync.Mutex, RWMutex & WaitGroup - shared state alternative
- Concurrency Fundamentals Best Practices - channel habits
Stack versions: This page was written for Go 1.26.x (Green Tea GC default, go fix modernizers - verify patch at build), chi (latest - verify at build), gin (latest - verify at build), echo (latest - verify at build), google.golang.org/grpc (latest - verify at build), sigs.k8s.io/controller-runtime (latest - verify at build), kubebuilder (latest - verify at build), tinygo (latest - verify board targets at build), wazero (latest - verify at build), and golangci-lint (latest - verify linter set at build).