Skip to main content
Go intermediate Lesson 15 of 25

Channels in Go

Use buffered and unbuffered channels, the select statement, done channels for cancellation, and fan-out/fan-in patterns.

Unbuffered Channels

Channels are Go’s primary mechanism for goroutines to communicate safely without shared memory. An unbuffered channel acts as a synchronisation point: the sender blocks until a receiver is ready, and the receiver blocks until a sender is ready. This makes them perfect for coordinating two goroutines that need to hand off a value at an exact moment, guaranteeing the data arrives before either side continues.

ch := make(chan int) // unbuffered — no internal queue

go func() {
    ch <- 42 // sender blocks here until the main goroutine receives
}()

val := <-ch  // receiver blocks here until the goroutine sends
fmt.Println(val) // 42 — guaranteed to be the value the goroutine sent

Buffered Channels

A buffered channel decouples the sender and receiver by adding an internal queue. The sender can deposit up to N values without waiting for a receiver — it only blocks when the buffer is full. This is useful when the producer naturally outpaces the consumer in short bursts, or when you want to limit concurrency using the channel as a counting semaphore.

ch := make(chan string, 3) // buffer holds up to 3 values

// All three sends complete immediately — no receiver needed yet
ch <- "first"
ch <- "second"
ch <- "third"
// ch <- "fourth" // would block here — buffer is full

fmt.Println(<-ch) // first  — values come out in FIFO order
fmt.Println(<-ch) // second
fmt.Println(<-ch) // third

Closing Channels

Closing a channel signals to receivers that no more values will be sent. This is the idiomatic way to broadcast “work is done” to one or many receivers. A range loop over a channel automatically stops when the channel is closed — you don’t need to track the count manually. The rule is simple: only the sender should ever close a channel.

ch := make(chan int)

go func() {
    for i := 0; i < 5; i++ {
        ch <- i // send values one by one
    }
    close(ch) // signal: no more values coming — safe because this goroutine is the sole sender
}()

// range receives all values, then exits automatically when the channel is closed
for v := range ch {
    fmt.Println(v) // 0, 1, 2, 3, 4
}

// Two-value form lets you distinguish "received a value" from "channel closed"
v, ok := <-ch
fmt.Println(v, ok) // 0 false — channel is closed, v is the zero value

Rules:

  • Only the sender should close a channel
  • Closing a closed channel panics
  • Sending to a closed channel panics
  • Receiving from a closed channel returns zero value with ok = false

Directional Channel Types

Function signatures can restrict a channel to send-only (chan<-) or receive-only (<-chan). This communicates intent clearly and prevents mistakes — a consumer function that accidentally tries to close or send on a channel it should only read from will fail at compile time. It also makes pipelines self-documenting: the types show the data flow direction.

func producer(out chan<- int) { // send-only — this function may only send
    for i := 0; i < 5; i++ {
        out <- i
    }
    close(out) // only the sender closes
}

func consumer(in <-chan int) { // receive-only — this function may only receive
    for v := range in {
        fmt.Println(v)
    }
}

func main() {
    ch := make(chan int) // bidirectional — converted automatically when passed
    go producer(ch)
    consumer(ch)
}

select Statement

select lets a goroutine wait on multiple channel operations simultaneously and react to whichever one becomes ready first. Without select, you would have to choose which channel to block on — and miss activity on the others. With select, a goroutine can multiplex many channels, implement non-blocking operations, and add timeouts, all in a single readable construct.

ch1 := make(chan string)
ch2 := make(chan string)

go func() { time.Sleep(1 * time.Second); ch1 <- "one" }()
go func() { time.Sleep(2 * time.Second); ch2 <- "two" }()

// select blocks until one of the cases is ready, then executes it
for i := 0; i < 2; i++ {
    select {
    case msg := <-ch1:
        fmt.Println("received from ch1:", msg)
    case msg := <-ch2:
        fmt.Println("received from ch2:", msg)
    }
}

Non-blocking operations with select

Adding a default case makes the select non-blocking — if no channel is ready, default runs immediately. This is useful for polling patterns where you want to check for a message but continue if none is available.

// Non-blocking receive — check for a message without waiting
select {
case msg := <-ch:
    fmt.Println("received:", msg)
default:
    fmt.Println("no message available right now")
}

// Non-blocking send — drop the value if nobody is listening
select {
case ch <- data:
    fmt.Println("sent")
default:
    fmt.Println("channel full — message dropped")
}

Timeout with select

Timeouts are one of the most important uses of select. By racing a result channel against time.After, you can bound how long a goroutine waits for an operation. This prevents a slow or unresponsive dependency from hanging your program indefinitely.

func fetchWithTimeout(url string, timeout time.Duration) (string, error) {
    result := make(chan string, 1) // buffered so the goroutine never leaks

    go func() {
        // simulate HTTP request
        time.Sleep(200 * time.Millisecond)
        result <- "response data"
    }()

    select {
    case data := <-result:
        return data, nil // operation completed in time
    case <-time.After(timeout):
        // timeout fired before result arrived
        return "", fmt.Errorf("request to %s timed out after %v", url, timeout)
    }
}

Done Channels — Cancellation

A done channel is a lightweight way to broadcast a stop signal to one or more goroutines. Closing the channel (rather than sending a value) is the right approach because a single close wakes up all receivers simultaneously — you don’t need to send one value per goroutine. In production code, context.Context is the standard way to do this, but understanding done channels first makes context cancellation much clearer.

func worker(id int, jobs <-chan int, done <-chan struct{}) {
    for {
        select {
        case job, ok := <-jobs:
            if !ok {
                return // jobs channel was closed — normal shutdown
            }
            fmt.Printf("worker %d processing job %d\n", id, job)
        case <-done:
            // done channel was closed — immediate cancellation
            fmt.Printf("worker %d cancelled\n", id)
            return
        }
    }
}

func main() {
    jobs := make(chan int, 10)
    done := make(chan struct{})

    for i := 1; i <= 3; i++ {
        go worker(i, jobs, done)
    }

    for i := 1; i <= 9; i++ {
        jobs <- i
    }

    time.Sleep(100 * time.Millisecond)
    close(done) // broadcasts stop signal to all three workers simultaneously
    time.Sleep(50 * time.Millisecond)
}

In production code, use context.Context instead of raw done channels — it’s the standard Go pattern.

Fan-Out Pattern

Fan-out distributes work from a single input channel across multiple goroutines. The benefit is parallelism: instead of one goroutine processing items one at a time, N workers process N items concurrently. This pattern is the foundation of worker pools and is effective whenever individual items take non-trivial time to process.

// fanOut starts `workers` goroutines, each reading from `in` and writing results to its own output channel
func fanOut(in <-chan int, workers int) []<-chan int {
    channels := make([]<-chan int, workers)
    for i := range channels {
        out := make(chan int)
        channels[i] = out
        go func(ch chan<- int) {
            for v := range in {
                ch <- v * v // each worker squares its received values
            }
            close(ch) // signal downstream that this worker is done
        }(out)
    }
    return channels
}

Fan-In Pattern

Fan-in is the complement to fan-out: it merges multiple input channels into a single output channel. This lets a single consumer receive results from many concurrent producers without needing to know how many there are. The sync.WaitGroup ensures the output channel is closed only after all input channels have been drained.

// fanIn merges any number of input channels into one output channel
func fanIn(channels ...<-chan int) <-chan int {
    out := make(chan int)
    var wg sync.WaitGroup

    // launch one forwarding goroutine per input channel
    forward := func(ch <-chan int) {
        defer wg.Done()
        for v := range ch {
            out <- v // forward every value to the single output
        }
    }

    wg.Add(len(channels))
    for _, ch := range channels {
        go forward(ch)
    }

    // close the output channel once all forwarders have finished
    go func() {
        wg.Wait()
        close(out)
    }()

    return out
}

Practical Example — Pipeline

Pipelines chain goroutines together using channels so that each stage processes data as it arrives, without waiting for the previous stage to complete all its work. This creates a streaming processing model: the first item reaches the last stage before the first stage has finished all items. The result is lower latency and better throughput compared to processing everything in one sequential pass.

package main

import (
    "fmt"
    "strings"
)

// Stage 1: emit words one at a time onto a channel
func generate(words ...string) <-chan string {
    out := make(chan string)
    go func() {
        for _, w := range words {
            out <- w
        }
        close(out) // no more words — signal downstream stages
    }()
    return out
}

// Stage 2: transform each word to uppercase
func toUpper(in <-chan string) <-chan string {
    out := make(chan string)
    go func() {
        for s := range in {
            out <- strings.ToUpper(s)
        }
        close(out)
    }()
    return out
}

// Stage 3: append an exclamation mark to each word
func exclaim(in <-chan string) <-chan string {
    out := make(chan string)
    go func() {
        for s := range in {
            out <- s + "!"
        }
        close(out)
    }()
    return out
}

func main() {
    // Connect the pipeline stages — each returns a channel the next stage reads from
    words := generate("hello", "world", "go", "channels")
    upper := toUpper(words)
    result := exclaim(upper)

    // Consume the final stage — range exits when exclaim closes its channel
    for word := range result {
        fmt.Println(word)
    }
    // HELLO!
    // WORLD!
    // GO!
    // CHANNELS!
}

Frequently Asked Questions

What is the difference between a buffered and an unbuffered channel?
An unbuffered channel (make(chan T)) requires both sender and receiver to be ready simultaneously — it synchronizes them. A buffered channel (make(chan T, N)) allows up to N sends without a receiver; the sender only blocks when the buffer is full.
What happens if you send to a closed channel?
Sending to a closed channel causes a panic. Receiving from a closed channel returns the zero value immediately with ok=false. Always close channels from the sender side, never the receiver.
When should I use a channel vs a mutex?
Use channels to pass data ownership between goroutines. Use mutexes to protect shared state that multiple goroutines need to access. A useful heuristic: if you're communicating data, use channels; if you're protecting a variable, use a mutex.