Skip to content
Codeloom
Go

Go Channel and Select Statement Patterns

Master Go channels and select statements with patterns for fan-out, fan-in, timeouts, cancellation, and pipeline composition.

·7 min read · By Codeloom
Intermediate 11 min read

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.
  • select multiplexes across channels. Add time.After for timeouts and default for 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.Context over done channels for cancellation in new code.