Go Channel and Select Statement Patterns
Master Go channels and select statements with patterns for fan-out, fan-in, timeouts, cancellation, and pipeline composition.
What you'll learn
- ✓Buffered vs unbuffered channels and when to use each
- ✓Select statement patterns for multiplexing and timeouts
- ✓Fan-out/fan-in and pipeline patterns for data processing
Prerequisites
- •Basic Go knowledge
- •Understanding of goroutines
Channels are Go’s mechanism for communication between goroutines. Combined with the select statement, they form a powerful toolkit for concurrent programming. This guide covers the patterns you will reach for in production.
Channel Basics
Unbuffered Channels
An unbuffered channel synchronizes sender and receiver. The sender blocks until the receiver is ready, and vice versa.
ch := make(chan string)
go func() {
ch <- "hello" // blocks until someone reads
}()
msg := <-ch // blocks until someone sends
fmt.Println(msg) // "hello"
Buffered Channels
A buffered channel holds up to N values. The sender blocks only when the buffer is full.
ch := make(chan int, 3)
ch <- 1 // does not block
ch <- 2 // does not block
ch <- 3 // does not block
// ch <- 4 // would block: buffer full
fmt.Println(<-ch) // 1
When to use buffered channels:
- When the producer is faster than the consumer and you want to smooth out bursts.
- As a semaphore to limit concurrency (
make(chan struct{}, maxWorkers)). - When you know exactly how many values will be sent.
Directional Channels
Restrict channels by direction to make APIs self-documenting.
// send-only channel
func produce(ch chan<- int) {
for i := 0; i < 10; i++ {
ch <- i
}
close(ch)
}
// receive-only channel
func consume(ch <-chan int) {
for v := range ch {
fmt.Println(v)
}
}
func main() {
ch := make(chan int, 5)
go produce(ch)
consume(ch)
}
The compiler enforces the direction. You cannot read from a chan<- or write to a <-chan.
The Select Statement
select lets a goroutine wait on multiple channel operations. It blocks until one of them can proceed.
select {
case msg := <-msgCh:
handleMessage(msg)
case err := <-errCh:
handleError(err)
}
Select with Timeout
Prevent indefinite blocking by adding a time.After case.
func fetchWithTimeout(ctx context.Context) (Result, error) {
ch := make(chan Result, 1)
go func() {
ch <- doExpensiveWork()
}()
select {
case result := <-ch:
return result, nil
case <-time.After(5 * time.Second):
return Result{}, fmt.Errorf("operation timed out")
case <-ctx.Done():
return Result{}, ctx.Err()
}
}
Non-Blocking Select with Default
The default case makes select non-blocking. Use it for “try-send” or “try-receive” patterns.
// Try to send without blocking
select {
case ch <- value:
// sent successfully
default:
// channel full or no receiver, drop the value
log.Println("dropped value, channel full")
}
// Try to receive without blocking
select {
case msg := <-ch:
process(msg)
default:
// nothing available right now
}
Pattern: Fan-Out
Distribute work from one channel to multiple workers.
func fanOut(jobs <-chan Job, numWorkers int) <-chan Result {
results := make(chan Result, numWorkers)
var wg sync.WaitGroup
for i := 0; i < numWorkers; i++ {
wg.Add(1)
go func(workerID int) {
defer wg.Done()
for job := range jobs {
result := process(job)
results <- result
}
}(i)
}
go func() {
wg.Wait()
close(results)
}()
return results
}
Usage:
jobs := make(chan Job, 100)
go func() {
for _, j := range allJobs {
jobs <- j
}
close(jobs)
}()
results := fanOut(jobs, 5)
for r := range results {
fmt.Println(r)
}
Pattern: Fan-In
Merge multiple channels into one.
func fanIn(channels ...<-chan string) <-chan string {
merged := make(chan string)
var wg sync.WaitGroup
for _, ch := range channels {
wg.Add(1)
go func(c <-chan string) {
defer wg.Done()
for msg := range c {
merged <- msg
}
}(ch)
}
go func() {
wg.Wait()
close(merged)
}()
return merged
}
This is useful when you have multiple producers (API calls, file readers, event streams) and want to process their output through a single consumer.
Pattern: Pipeline
A pipeline is a series of stages connected by channels. Each stage is a goroutine that reads from an input channel, transforms the data, and sends it to an output channel.
func generate(nums ...int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for _, n := range nums {
out <- n
}
}()
return out
}
func square(in <-chan int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for n := range in {
out <- n * n
}
}()
return out
}
func filter(in <-chan int, predicate func(int) bool) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for n := range in {
if predicate(n) {
out <- n
}
}
}()
return out
}
Compose the pipeline:
func main() {
nums := generate(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
squared := square(nums)
big := filter(squared, func(n int) bool { return n > 20 })
for v := range big {
fmt.Println(v) // 25, 36, 49, 64, 81, 100
}
}
Pattern: Done Channel for Cancellation
Before context was widespread, the “done channel” pattern was used for cancellation. You will still see it in older code.
func worker(done <-chan struct{}, jobs <-chan Job) <-chan Result {
results := make(chan Result)
go func() {
defer close(results)
for {
select {
case <-done:
return
case job, ok := <-jobs:
if !ok {
return
}
results <- process(job)
}
}
}()
return results
}
func main() {
done := make(chan struct{})
jobs := make(chan Job, 10)
results := worker(done, jobs)
// Send some jobs
for i := 0; i < 5; i++ {
jobs <- Job{ID: i}
}
// Cancel the worker
close(done)
}
In new code, prefer context.Context for cancellation.
Pattern: Ticker and Periodic Work
Use time.Ticker with select for periodic operations.
func pollHealth(ctx context.Context, url string, interval time.Duration) <-chan bool {
results := make(chan bool)
ticker := time.NewTicker(interval)
go func() {
defer ticker.Stop()
defer close(results)
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
resp, err := http.Get(url)
if err != nil {
results <- false
continue
}
resp.Body.Close()
results <- resp.StatusCode == 200
}
}
}()
return results
}
Pattern: Or-Done Channel
Wait for the first of N channels to produce a value.
func orDone(channels ...<-chan struct{}) <-chan struct{} {
switch len(channels) {
case 0:
return nil
case 1:
return channels[0]
}
done := make(chan struct{})
go func() {
defer close(done)
select {
case <-channels[0]:
case <-channels[1]:
case <-orDone(channels[2:]...):
}
}()
return done
}
This is useful for racing multiple cancellation sources.
Channel Gotchas
Sending on a Closed Channel Panics
ch := make(chan int)
close(ch)
ch <- 1 // panic: send on closed channel
Only the sender should close a channel, and only when there are no more values to send.
Reading from a Closed Channel Returns the Zero Value
ch := make(chan int)
close(ch)
v := <-ch // v is 0, ok is false
Use the two-value form to detect a closed channel:
v, ok := <-ch
if !ok {
// channel is closed
}
Nil Channels Block Forever
A nil channel blocks on both send and receive. This is useful for disabling a select case.
var ch chan int // nil
select {
case v := <-ch: // never selected
fmt.Println(v)
case <-time.After(time.Second):
fmt.Println("timeout")
}
Summary
- Use unbuffered channels for synchronization and buffered channels for decoupling speed differences.
- Use directional channels (
chan<-and<-chan) to express intent in function signatures. selectmultiplexes across channels. Addtime.Afterfor timeouts anddefaultfor non-blocking operations.- Fan-out distributes work to N workers. Fan-in merges N channels into one.
- Pipelines chain stages with channels for composable data processing.
- Always close channels from the sender side. Never send on a closed channel.
- Prefer
context.Contextover done channels for cancellation in new code.
Related articles
- Go Go Concurrency: Fan-Out, Pipeline, and Worker Pool
Master advanced Go concurrency patterns including fan-out/fan-in, pipelines, and worker pools with practical examples and production-ready code.
- Go Go Goroutines and Channels Tutorial
A practical introduction to concurrency in Go: goroutines, channels, select, common patterns like fan-out/fan-in, and the pitfalls that cause leaks and races.
- Go Race Detector and Debugging Concurrent Go Code
Use Go's race detector to find and fix data races, plus tools and patterns for debugging concurrent programs effectively.
- Go Go Sync Primitives: Mutex, RWMutex, WaitGroup, and Once
A practical guide to Go sync primitives including Mutex, RWMutex, WaitGroup, Once, and when to choose each over channels.