Buffered vs Unbuffered Channels

A channel in Go is a typed pipe that goroutines use to send and receive values safely, without needing locks. Channels come in two flavors that behave very differently at the moment of a send or receive: unbuffered channels, which force the sender and receiver to meet at exactly the same instant, and buffered channels, which let a sender drop off up to a fixed number of values before it has to wait for anyone to pick them up. Picking the wrong one is one of the most common ways to accidentally write a Go program that deadlocks, or one that silently leaks goroutines. This lesson explains exactly how each kind works under the hood, when to reach for which, and the mistakes that trip up almost everyone the first time.

Overview: How Channels Work Under the Hood

Every channel you create with make is backed by a small piece of runtime state containing a mutex, a queue of goroutines waiting to send, a queue of goroutines waiting to receive, and — for buffered channels only — a fixed-size ring buffer that holds pending values. A channel value itself, like a slice header, is really a pointer to this shared state; copying a channel copies the pointer, not the underlying queue, which is exactly why passing a channel around by value between goroutines is safe and idiomatic.

An unbuffered channel is created with make(chan T) — no capacity argument, so its capacity is zero. A send on an unbuffered channel, ch <- v, cannot complete until another goroutine is simultaneously ready to execute a receive, <-ch, on that same channel. If no one is waiting to receive, the sending goroutine is parked (removed from its OS thread and added to the channel’s send queue) until a receiver shows up. This forced meeting point is often called a rendezvous, and it makes an unbuffered channel behave as much like a synchronization primitive as a data-passing one. Go’s memory model guarantees that a send on an unbuffered channel happens-before the corresponding receive completes, meaning every memory write the sender made before the send is guaranteed visible to the receiver after the receive returns. That guarantee is exactly why “send a value” or “close a channel” is such a common, reliable way to signal “I’m done” between goroutines.

A buffered channel is created with make(chan T, n), where n is a positive capacity. Internally the runtime allocates a ring buffer with room for n values. A send only blocks if the buffer is already full (n values stored and not yet received); a receive only blocks if the buffer is empty. This decouples the sender and receiver in time: the sender is free to race ahead of the receiver by up to n sends before it must wait. You can inspect this state at any time with len(ch), which returns the number of values currently sitting in the buffer, and cap(ch), which returns the buffer’s total capacity.

Two more facts round out the picture. First, the zero value of a channel is nil, and sending or receiving on a nil channel blocks forever — this is occasionally used deliberately inside a select statement to disable a case, but if it happens by accident it looks exactly like a deadlock. Second, closing a channel with close(ch) does not empty a buffered channel — values already sitting in the buffer can still be received after close — but it does immediately unblock every goroutine currently waiting to receive, delivering them the element’s zero value along with ok == false once the buffer is fully drained.

Syntax

ch := make(chan T)       // unbuffered channel of element type T
ch := make(chan T, n)    // buffered channel of element type T, capacity n

ch <- v                  // send v on ch (blocks per the rules above)
v := <-ch                // receive from ch (blocks per the rules above)
v, ok := <-ch            // ok is false only once ch is closed and drained

close(ch)                // close ch -- only the sender should ever do this
len(ch)                  // number of values currently buffered
cap(ch)                  // the channel's total buffer capacity
Piece Meaning
make(chan T) Creates an unbuffered channel; every send waits for a matching receive.
make(chan T, n) Creates a buffered channel with room for n values before a send blocks.
ch <- v Sends v into the channel ch.
<-ch Receives the next value from ch, blocking until one is available.
close(ch) Marks the channel finished; further sends panic, further receives drain the buffer then return the zero value.

Examples

Example 1: An Unbuffered Channel as a Rendezvous

The simplest use of a channel is passing one value from a goroutine back to main. Because the channel is unbuffered, the receive in main won’t return until the goroutine actually executes its send.

package main

import "fmt"

func main() {
	ch := make(chan string)

	go func() {
		ch <- "hello from goroutine"
	}()

	msg := <-ch
	fmt.Println(msg)
}

Output:

hello from goroutine

The main goroutine blocks on <-ch the instant it reaches that line. Nothing is printed until the spawned goroutine reaches ch <- "hello from goroutine", at which point the two goroutines rendezvous, the value is handed off, both continue, and main prints the message.

Example 2: Using an Unbuffered Channel to Signal Completion

Because a send/receive pair on an unbuffered channel establishes a happens-before relationship, you can use one purely as a “done” signal, with no data of real interest in the value itself.

package main

import (
	"fmt"
	"time"
)

func main() {
	done := make(chan bool)

	go func() {
		fmt.Println("worker: starting work")
		time.Sleep(50 * time.Millisecond)
		fmt.Println("worker: work complete")
		done <- true
	}()

	<-done
	fmt.Println("main: worker finished, exiting")
}

Output:

worker: starting work
worker: work complete
main: worker finished, exiting

main blocks on <-done until the worker goroutine finishes its simulated work and sends. Because that send cannot happen until both worker Println calls have already executed, the ordering shown above is guaranteed, not just likely.

Example 3: A Buffered Channel Holding Values Without a Receiver

With a buffered channel, sends succeed immediately as long as there is free space — no receiver needs to be ready.

package main

import "fmt"

func main() {
	ch := make(chan int, 3)

	ch <- 1
	ch <- 2
	ch <- 3

	fmt.Println("length:", len(ch), "capacity:", cap(ch))

	close(ch)

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

Output:

length: 3 capacity: 3
received: 1
received: 2
received: 3

All three sends complete without any goroutine standing by to receive, because the buffer has room for three values. len(ch) confirms three values are queued. After close, ranging over the channel drains the buffer in FIFO order and then exits the loop automatically once it’s empty.

Example 4: A Realistic Worker Pool Using a Buffered Results Channel

A very common real pattern: fan a job list out to goroutines, have each one write its result into a buffered channel sized to the number of jobs so no goroutine ever blocks trying to hand off its result, then collect everything once a WaitGroup confirms all workers are done.

package main

import (
	"fmt"
	"sync"
)

func main() {
	jobs := []int{1, 2, 3, 4, 5}
	results := make(chan int, len(jobs))

	var wg sync.WaitGroup
	for _, j := range jobs {
		wg.Add(1)
		go func(n int) {
			defer wg.Done()
			results <- n * n
		}(j)
	}

	wg.Wait()
	close(results)

	sum := 0
	for r := range results {
		sum += r
	}
	fmt.Println("sum of squares:", sum)
}

Output:

sum of squares: 55

Sizing results to len(jobs) guarantees every worker’s send succeeds instantly, regardless of scheduling order, so no worker ever blocks waiting on a slow collector. wg.Wait() blocks main until every worker has called wg.Done() (which happens right after its send), so it’s safe to close(results) and drain it afterward.

How It Works Step by Step

When a goroutine executes a send, ch <- v, the runtime performs roughly these steps:

  1. It locks the channel’s internal state so no other goroutine can interleave with this operation.
  2. If another goroutine is already parked waiting to receive, the runtime copies v directly into that goroutine’s memory and wakes it. This “direct handoff” happens for both buffered and unbuffered channels whenever a receiver is already waiting, skipping the ring buffer entirely.
  3. Otherwise, for a buffered channel with a free slot, the runtime copies v into the next slot of the ring buffer, increments the stored count, and the sender continues immediately without blocking.
  4. If neither applies — no waiting receiver, and, for buffered channels, no free slot — the sending goroutine is parked: removed from its OS thread and added to the channel’s send-wait queue, and the scheduler runs some other goroutine in the meantime.
  5. The parked sender is only woken, and its value copied across, once a receiver arrives (unbuffered) or a slot frees up (buffered).

Receiving is the mirror image: check for a waiting sender to hand off from directly, then check the buffer for a value to dequeue, and only park the receiving goroutine if neither is available. Closing a channel walks the wait queues and wakes every parked goroutine immediately — handing receivers the zero value and ok == false once nothing real is left to deliver, and causing any parked sender to panic instead. That’s exactly why you should never close a channel that another goroutine might still be sending on.

Common Mistakes

Mistake 1: Sending on an Unbuffered Channel With No Receiver

If nothing else in the program will ever receive from an unbuffered channel, a send on it blocks forever. When the Go runtime detects that every goroutine in the program is blocked with no possibility of progress, it doesn’t hang silently — it crashes with a fatal deadlock error.

package main

func main() {
	ch := make(chan int)
	ch <- 42 // deadlock: nobody is receiving
}

Output:

fatal error: all goroutines are asleep - deadlock!

The fix is to make sure a receiver actually exists and is running concurrently before (or while) the send happens:

package main

import "fmt"

func main() {
	ch := make(chan int)

	go func() {
		ch <- 42
	}()

	fmt.Println(<-ch)
}

Output:

42

Mistake 2: Assuming a Buffered Channel Can Never Block

A buffered channel only postpones blocking — it doesn’t eliminate it. Sending more values than the capacity, with nothing draining the channel concurrently, blocks exactly like an unbuffered channel once the buffer fills up.

package main

func main() {
	ch := make(chan int, 2)
	ch <- 1
	ch <- 2
	ch <- 3 // blocks forever: buffer is full, nobody is receiving
}

Output:

fatal error: all goroutines are asleep - deadlock!

The fix is to have a receiver draining the channel concurrently with the sends, rather than assuming the buffer alone is enough:

package main

import "fmt"

func main() {
	ch := make(chan int, 2)
	done := make(chan bool)

	go func() {
		for v := range ch {
			fmt.Println("received:", v)
		}
		done <- true
	}()

	for i := 1; i <= 5; i++ {
		ch <- i
	}
	close(ch)
	<-done
}

Output:

received: 1
received: 2
received: 3
received: 4
received: 5

Mistake 3: Sending on a Closed Channel

Closing a channel signals “no more values are coming.” Sending on a channel after it’s been closed is a programmer error the runtime refuses to allow silently — it panics immediately.

package main

func main() {
	ch := make(chan int, 1)
	close(ch)
	ch <- 1 // panic: send on closed channel
}

Output:

panic: send on closed channel

The fix is to make sure sends only ever happen before close, and to use the comma-ok form when receiving so you can tell a real zero value apart from a closed, drained channel:

package main

import "fmt"

func main() {
	ch := make(chan int, 1)
	ch <- 1
	close(ch)

	v, ok := <-ch
	fmt.Println(v, ok)

	v, ok = <-ch
	fmt.Println(v, ok)
}

Output:

1 true
0 false

Best Practices

  • Default to unbuffered channels when the goal is synchronization or signaling; reach for a buffered channel only when you have a concrete reason, such as a known, fixed number of results or decoupling a producer’s rate from a consumer’s.
  • Only the sender should ever call close on a channel, and only once all sends are finished — never close from the receiving side, and never close a channel that another goroutine might still be sending on.
  • Size a buffered channel deliberately and document why that number was chosen. An oversized buffer doesn’t fix a design mistake, it just delays a deadlock into a slow memory leak.
  • Use the comma-ok form, v, ok := <-ch, whenever a channel might be closed, so you can distinguish a genuine zero value from a closed, drained channel.
  • When you only need to know that some goroutines finished, and don’t need to pass data back, prefer sync.WaitGroup or a context.Context over an ad hoc channel.
  • Always have an explicit plan for how every goroutine that sends on a channel will eventually stop — an unbounded goroutine that’s permanently blocked on a send nobody will ever receive is a classic Go memory leak.

Practice Exercises

  1. Write a program where two goroutines “ping-pong” an integer back and forth over an unbuffered channel five times, printing each value as it’s received.
  2. Create a buffered channel with capacity 3, launch a goroutine that sends 10 integers into it, and have main receive and print all 10. Explain in a comment why this doesn’t deadlock even though 10 is greater than the capacity.
  3. Take the worker pool example from this lesson and change results to an unbuffered channel. Explain (in a comment) why the program now deadlocks, and fix it using a separate goroutine that closes results after wg.Wait() while main ranges over results concurrently.

Summary

  • An unbuffered channel, make(chan T), forces the sender and receiver to rendezvous at the same instant; neither completes its operation alone.
  • A buffered channel, make(chan T, n), lets sends succeed immediately until its ring buffer of capacity n is full, decoupling sender and receiver in time.
  • A send on an unbuffered channel happens-before the matching receive completes, which is why unbuffered channels double as a reliable synchronization signal.
  • len(ch) and cap(ch) report a buffered channel’s current occupancy and total capacity.
  • Sending with no possible receiver, or filling a buffer with nobody draining it, causes a runtime deadlock; sending on a closed channel causes a panic.
  • Only the sender should close a channel, and only after all sends are done; receivers should use the comma-ok form to detect a closed, drained channel.