Skip to main content

Channels: Communication Between Goroutines in Go

Published: December 17, 2025 Updated: August 29, 2026 Larry Qu 7 min read

Go’s concurrency model is built around a simple idea: instead of sharing memory between goroutines and protecting it with locks, communicate by passing data through channels. A channel is a typed conduit — one goroutine puts a value in, another takes it out. The transfer is synchronized: the sender waits until there’s a receiver, and the receiver waits until there’s a sender (unless the channel is buffered).

This forces explicit data handoff, which makes concurrent code easier to reason about than shared-memory models. Channels don’t eliminate the need for sync primitives — sometimes a mutex is the right tool — but they’re the idiomatic Go mechanism for coordinating work between goroutines.

For related topics see Go goroutines, Go worker pools, and Go advanced channel patterns.

Unbuffered Channels: Synchronized Handoff

An unbuffered channel (created with make(chan T)) requires both a sender and a receiver to be ready simultaneously. The send blocks until someone receives, and the receive blocks until someone sends. This synchronization is the key property:

ch := make(chan int)

go func() {
    fmt.Println("computing...")
    ch <- 42  // blocks until main receives
}()

result := <-ch  // blocks until goroutine sends
fmt.Println(result)

Because both sides must meet, an unbuffered channel acts as a rendezvous point — useful when you want to know that the receiver has the value, not just that the value has been sent.

Buffered Channels: Decoupling Sender from Receiver

A buffered channel has a queue. The sender can put values in up to the buffer capacity without waiting for a receiver. Only when the buffer is full does the sender block:

ch := make(chan int, 3)  // buffer capacity 3

ch <- 1  // doesn't block — goes in the buffer
ch <- 2
ch <- 3
// ch <- 4  would block here — buffer full

fmt.Println(<-ch) // 1
fmt.Println(<-ch) // 2
fmt.Println(<-ch) // 3

The buffer acts as a queue (FIFO). Use buffered channels to smooth out bursts between a fast producer and a slower consumer, or to avoid the goroutine leak where a goroutine sends one result and exits — if the receiver might never read it, a buffer of 1 lets the goroutine always complete:

// ✅ Buffer of 1 prevents a goroutine leak if the caller abandons the channel
func compute() <-chan int {
    ch := make(chan int, 1)
    go func() {
        ch <- expensiveWork()  // can always complete, even if caller is gone
    }()
    return ch
}

Closing Channels

Closing a channel signals that no more values will be sent. Receivers can detect this:

ch := make(chan int, 5)
for i := 0; i < 5; i++ {
    ch <- i
}
close(ch)  // only the sender should close

// range exits cleanly when the channel is closed and drained
for v := range ch {
    fmt.Println(v)  // 0 1 2 3 4
}

Two-value receive lets you distinguish “received a value” from “channel closed”:

v, ok := <-ch
if !ok {
    fmt.Println("channel closed")
}

The most important rule: only the sender should close a channel. Closing from the receiver side, or closing twice, causes a panic. If multiple goroutines might send, use a sync.WaitGroup to know when all senders are done, then close in a single coordinating goroutine:

var wg sync.WaitGroup
ch := make(chan int, numWorkers)

for i := 0; i < numWorkers; i++ {
    wg.Add(1)
    go func(id int) {
        defer wg.Done()
        ch <- id * 2
    }(i)
}

go func() {
    wg.Wait()
    close(ch)  // safe: all senders are done
}()

for v := range ch {
    fmt.Println(v)
}

The select Statement

select lets a goroutine wait on multiple channel operations at once — it picks whichever case is ready, randomly if multiple are ready simultaneously:

func merge(ch1, ch2 <-chan string) <-chan string {
    out := make(chan string)
    go func() {
        defer close(out)
        for {
            select {
            case v, ok := <-ch1:
                if !ok { ch1 = nil }  // nil channel never selects
                else { out <- v }
            case v, ok := <-ch2:
                if !ok { ch2 = nil }
                else { out <- v }
            }
            if ch1 == nil && ch2 == nil {
                return
            }
        }
    }()
    return out
}

Setting a closed channel to nil is the correct way to remove it from a select — a nil channel blocks forever, so it’s effectively removed from consideration.

default case makes select non-blocking — if no channel is ready, the default runs immediately:

select {
case v := <-resultCh:
    process(v)
default:
    fmt.Println("no result yet, doing other work")
}

Timeout with time.After:

select {
case v := <-ch:
    fmt.Println("got:", v)
case <-time.After(2 * time.Second):
    fmt.Println("timed out")
}

For production code, prefer context.WithTimeout over time.Aftertime.After creates a timer that can’t be cancelled early, which leaks a goroutine until the timer fires.

Directional Channels: Enforcing Ownership

When you pass a channel to a function, declare which direction the function uses it. This documents intent and prevents bugs — the compiler enforces it:

func producer(out chan<- int) {  // can only send
    for i := 0; i < 5; i++ {
        out <- i
    }
    close(out)
}

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

func main() {
    ch := make(chan int, 5)
    go producer(ch)  // bidirectional chan converts to send-only automatically
    consumer(ch)     // converts to receive-only automatically
}

If producer accidentally tried to receive from out, the compiler would reject it. Directional channels make channel ownership clear at a glance.

Channel Ownership Pattern

The convention that prevents most channel bugs: the goroutine that creates a channel owns it, is responsible for closing it, and is the only one that sends on it. Receivers never close.

// Owner: creates, sends, closes
func generate(ctx context.Context, nums []int) <-chan int {
    ch := make(chan int, len(nums))  // sized to avoid blocking
    go func() {
        defer close(ch)  // owner closes
        for _, n := range nums {
            select {
            case ch <- n:
            case <-ctx.Done():
                return
            }
        }
    }()
    return ch  // return receive-only — caller can't close or send
}

// Consumer: only receives, never closes
func sum(in <-chan int) int {
    total := 0
    for v := range in {
        total += v
    }
    return total
}

Returning <-chan int (receive-only) from generate makes it impossible for callers to close or send on the channel. This is a compile-time guarantee of the ownership contract.

Common Mistakes

Sending on a closed channel panics immediately. This usually happens when multiple goroutines each try to close the channel, or when the producer closes prematurely while another sender is still running. Use sync.WaitGroup and a single close call.

Deadlock: all goroutines asleep. The Go runtime detects this and panics with all goroutines are asleep — deadlock!. Common causes:

  • Sending to an unbuffered channel with no receiver running
  • Receiving from an empty channel with no sender
  • Circular send/receive between goroutines

Goroutine leaks from abandoned channels. If you create a goroutine that sends on a channel and the receiver exits without draining it, the goroutine blocks forever. Pass a context.Context and check ctx.Done() in every select.

Using len(ch) and cap(ch) for logic. These values are snapshots that can change by the time you act on them. Don’t use channel length for control flow — use the receive itself.

Summary

  • Unbuffered channels synchronize: send blocks until receive, receive blocks until send
  • Buffered channels queue: sender only blocks when buffer is full, useful for decoupling producer/consumer speeds
  • Only the sender (owner) should close a channel — never the receiver
  • select multiplexes over channel operations; nil channels never select and can be used to “disable” a case
  • Use directional channel types (chan<-, <-chan) in function signatures to document and enforce ownership
  • Pass context.Context through channels for cancellation — never rely on bare done chan struct{} in new code

Resources

Comments

👍 Was this article helpful?