Channels in Go From Unbuffered to Buffered and Back Again
A full guide to Go channels: send and receive, blocking, buffered and unbuffered behavior, close and range, directions, nil channels, ownership and costs.

Channels are the part of Go that gets drawn on slides. They are how goroutines hand values to each other, and they are the origin of the slogan "do not communicate by sharing memory; share memory by communicating". They are also, in practice, a tool with sharp edges: a send that never completes, a receive that returns a zero value you mistook for data, a close that happens twice. The rules are few, and each one explains a bug someone has shipped.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64)
This guide builds the picture from the first send to the costs, with a runnable example for every rule. It follows goroutines explained and leads to buffered versus unbuffered channels, select and closing channels. Everything ran on Go 1.25.6.
Making, sending and receiving
A channel has an element type and is created with make. The operator <- sends and receives, with the arrow pointing the way the value moves:
package main
import "fmt"
func main() {
ch := make(chan string)
go func() {
ch <- "hello from a goroutine" // send
}()
msg := <-ch // receive; waits until a value arrives
fmt.Println(msg)
}hello from a goroutineThe main goroutine reaches <-ch and waits, because nothing has been sent yet. The other goroutine sends, the value is handed over directly, and both continue. No sleep, no flag, no lock: the channel is the synchronisation. If the goroutine had never sent, main would wait forever and the runtime would report a deadlock (the deadlock error).
Unbuffered: a handshake
A channel made with make(chan T) has no buffer. A send cannot complete until a receiver takes the value, and a receive cannot complete until a sender provides one. The two goroutines meet:
package main
import (
"fmt"
"time"
)
func main() {
ch := make(chan int)
go func() {
for i := 1; i <= 3; i++ {
fmt.Println("sending", i)
ch <- i
fmt.Println("sent", i)
}
close(ch)
}()
for v := range ch {
time.Sleep(20 * time.Millisecond) // a slow consumer
fmt.Println("received", v)
}
}sending 1
sent 1
sending 2
received 1
sent 2
sending 3
received 2
sent 3
received 3The sender cannot get ahead. It prints sending 1, blocks until the consumer takes it, and sent 1 appears only once the receive has happened. The timing of the interleaved lines varies slightly, which is why the ordering of "sent" against "received" is not guaranteed even though the handoff is. What is guaranteed is the happens-before relationship: everything the sender did before the send is visible to the receiver after the receive (the memory model). That makes an unbuffered channel a clean way to pass ownership of a value from one goroutine to another.
Buffered: a queue of fixed size
make(chan T, n) adds a buffer of n elements. A send completes immediately while there is room, and blocks only when the buffer is full. A receive takes from the front and blocks only when it is empty.
package main
import "fmt"
func main() {
ch := make(chan int, 3)
ch <- 1
ch <- 2
fmt.Println("len:", len(ch), "cap:", cap(ch))
ch <- 3
select {
case ch <- 4:
fmt.Println("sent 4")
default:
fmt.Println("buffer full: a fourth send would block")
}
fmt.Println(<-ch, <-ch, <-ch)
}len: 2 cap: 3
buffer full: a fourth send would block
1 2 3The three sends do not block, len reports the queued elements and cap the buffer size, and the fourth send would block, which the select with a default shows without hanging. Values come out in the order they went in. A buffer decouples the two sides by a fixed amount: a producer can run n items ahead of a slow consumer. It does not make the pipeline faster in steady state, because throughput is set by the slower side, and it can hide a deadlock for as long as the buffer has room. Choosing between them covers the decision, and the short version is to start unbuffered and add a buffer for a reason you can state.
Closing and ranging
Closing a channel says that no more values will be sent. Receivers can still drain what is buffered, then every receive returns the zero value at once, with a second result that tells you the channel is closed:
package main
import "fmt"
func main() {
ch := make(chan int, 2)
ch <- 10
ch <- 20
close(ch)
v, ok := <-ch
fmt.Println(v, ok)
v, ok = <-ch
fmt.Println(v, ok)
v, ok = <-ch // closed and drained
fmt.Println(v, ok)
data := make(chan string)
go func() {
defer close(data)
for _, w := range []string{"a", "b", "c"} {
data <- w
}
}()
for w := range data { // ends when data is closed
fmt.Print(w, " ")
}
fmt.Println()
}10 true
20 true
0 false
a b cThe first two receives return the buffered values with ok == true. The third returns 0 false: the zero value, and a signal that it did not come from a send. A for range over a channel does the same check for you and ends when the channel is closed and empty, which is the usual way to consume a stream. The sender does the closing, with defer close(data) so it happens on every path out of the goroutine. A receiver must not close, because the sender may still be sending, and a send on a closed channel panics (send on closed channel and who may close). Closing is optional: a channel with no more users is garbage collected like anything else, and you close only to tell receivers to stop.
Directions in types
A function that only sends or only receives can say so, and the compiler enforces it:
package main
import "fmt"
func produce(out chan<- int) { // can only send
for i := 1; i <= 3; i++ {
out <- i
}
close(out)
}
func consume(in <-chan int) int { // can only receive
sum := 0
for v := range in {
sum += v
}
return sum
}
func main() {
ch := make(chan int)
go produce(ch)
fmt.Println(consume(ch))
}6chan<- int is send-only and <-chan int is receive-only. A bidirectional channel converts to either implicitly, so main passes ch to both. The payoff is that consume cannot close the channel, which is not its job, and trying to would be a compile error. Directions in signatures are the cheapest documentation of ownership that Go offers (goroutine ownership). A function that returns a channel should return <-chan T when the caller should only read it.
Nil channels
The zero value of a channel is nil, and a nil channel blocks forever on both send and receive. That sounds like a bug generator, and it is also a feature: inside a select, a case on a nil channel is never chosen, so setting a channel variable to nil switches that case off.
package main
import "fmt"
func main() {
a := make(chan int)
b := make(chan int)
go func() { a <- 1; close(a) }()
go func() { b <- 2; close(b) }()
for a != nil || b != nil {
select {
case v, ok := <-a:
if !ok {
a = nil // stop selecting on a
continue
}
fmt.Println("a:", v)
case v, ok := <-b:
if !ok {
b = nil
continue
}
fmt.Println("b:", v)
}
}
fmt.Println("both done")
}b: 2
a: 1
both doneThe loop merges two channels and stops cleanly when both have closed, because a closed channel would otherwise return zero values forever and spin the loop. Nilling it removes it from the select (nil channels in select explores the pattern). It is also the reason to initialise channels before use: a forgotten make produces a goroutine that hangs without any error.
Signalling with struct{}
When the channel carries a signal and not data, use chan struct{}. The empty struct takes no memory, and the type says "there is no payload". Closing the channel broadcasts to every receiver at once, which a send cannot do:
package main
import (
"fmt"
"sync"
)
func main() {
start := make(chan struct{})
var wg sync.WaitGroup
var mu sync.Mutex
order := 0
for i := range 3 {
wg.Go(func() {
<-start // all three wait here
mu.Lock()
order++
mu.Unlock()
_ = i
})
}
close(start) // releases all of them
wg.Wait()
fmt.Println("released:", order)
}released: 3One close wakes every waiter. This is the base of the done-channel pattern and of how context.Done() works (the done channel, context).
What a channel costs
Channels are not free, and a comparison puts the cost in proportion. This benchmark measures one handoff between two goroutines through an unbuffered channel, through a buffered one, and, for scale, an uncontended mutex lock and unlock:
package main
import (
"sync"
"testing"
)
func BenchmarkUnbufferedHandoff(b *testing.B) {
ch := make(chan int)
done := make(chan struct{})
go func() {
for range ch {
}
close(done)
}()
for i := 0; i < b.N; i++ {
ch <- i
}
close(ch)
<-done
}
func BenchmarkBufferedHandoff(b *testing.B) {
ch := make(chan int, 128)
done := make(chan struct{})
go func() {
for range ch {
}
close(done)
}()
for i := 0; i < b.N; i++ {
ch <- i
}
close(ch)
<-done
}
func BenchmarkMutexLockUnlock(b *testing.B) {
var mu sync.Mutex
n := 0
for i := 0; i < b.N; i++ {
mu.Lock()
n++
mu.Unlock()
}
_ = n
}goos: darwin
goarch: arm64
pkg: example.test/lab
cpu: Apple M1 Max
BenchmarkUnbufferedHandoff-10 7688710 138.2 ns/op 0 B/op 0 allocs/op
BenchmarkUnbufferedHandoff-10 8051131 135.9 ns/op 0 B/op 0 allocs/op
BenchmarkUnbufferedHandoff-10 8902704 135.4 ns/op 0 B/op 0 allocs/op
BenchmarkBufferedHandoff-10 37044562 32.37 ns/op 0 B/op 0 allocs/op
BenchmarkBufferedHandoff-10 36946238 32.65 ns/op 0 B/op 0 allocs/op
BenchmarkBufferedHandoff-10 36712309 32.00 ns/op 0 B/op 0 allocs/op
BenchmarkMutexLockUnlock-10 86835913 13.65 ns/op 0 B/op 0 allocs/op
BenchmarkMutexLockUnlock-10 89807197 13.53 ns/op 0 B/op 0 allocs/op
BenchmarkMutexLockUnlock-10 88523594 13.57 ns/op 0 B/op 0 allocs/op
PASS
ok example.test/lab 11.377sOn an Apple M1 Max the medians were about 136 ns for an unbuffered handoff, 32 ns for a buffered one and 13.6 ns for an uncontended mutex lock and unlock, with no allocations in any of them. The unbuffered case is the slowest because each handoff involves waking the other goroutine, a buffered handoff is cheaper per item since the sender rarely blocks, and the mutex is cheaper again. The lesson is not "avoid channels"; at roughly a hundred nanoseconds, a channel is invisible next to a network call or a disk read. It is "don't put a channel in the inner loop of a hot computation", and "use a mutex or an atomic for a counter, a channel for handing over work" (channels or mutexes).
A request and its reply
A channel can carry another channel. The pattern turns a goroutine into a small server: callers send a request that includes a channel for the answer, and the server replies on it. It is the basis of the actor style (actor-style concurrency).
package main
import "fmt"
type request struct {
n int
reply chan int
}
func server(reqs <-chan request) {
for r := range reqs {
r.reply <- r.n * r.n
}
}
func main() {
reqs := make(chan request)
go server(reqs)
for _, n := range []int{3, 4, 5} {
reply := make(chan int, 1)
reqs <- request{n, reply}
fmt.Println(n, "squared is", <-reply)
}
close(reqs)
}3 squared is 9
4 squared is 16
5 squared is 25Each request carries its own reply channel with capacity 1, so the server's send never blocks even if the caller has stopped listening. That detail is the difference between a server that survives a client walking away and one that hangs. The server goroutine owns whatever state it keeps, and nothing else touches it, so there is no lock.
How a channel works inside
Knowing the structure explains the costs and the rules. The runtime represents a channel as a small struct holding a lock, a circular buffer for buffered channels, and two queues of waiting goroutines, one of senders and one of receivers. A send on a channel that has a waiting receiver hands the value straight to that goroutine and wakes it, with no trip through the buffer. A send with no receiver and a full (or absent) buffer parks the sender in the send queue, and a later receive takes the value from it and wakes it. close wakes every waiter. That is why an unbuffered handoff costs a goroutine wake-up, and why a buffered send that finds room is cheap: it copies into the ring and returns (the channel implementation walks through the source). Because the struct has a lock, a channel shared by many senders and receivers contends like a mutex does, and a hot channel can become a bottleneck that sharding or batching fixes.
Testing code that uses channels
Tests of channel code have two ways to go wrong: hanging forever, and passing by luck. Defend against both. Give every test a timeout, either with go test -timeout or with a select on time.After around a receive, so a missing send fails the test and does not hang the suite. Check that goroutines exit: close the input, wait for the output to close, and assert the count of goroutines afterwards (testing concurrent code). Run with -race, which finds the unsynchronised access that a channel was supposed to prevent. And avoid sleeps as synchronisation: wait for a value, not for time.
Channels in the standard library
You meet channels in several standard packages, and they follow the rules above. time.After and time.Ticker deliver on a channel (tickers). os/signal.Notify sends signals to a channel you provide, which should be buffered so the sender never blocks (signals). context.Done() returns a channel that is closed on cancellation, the broadcast pattern from earlier. Reading these as ordinary channels makes them easy to combine in a select with your own.
Choosing channels deliberately
It helps to have a rule for when a channel is the right tool. Reach for one when the main event is a value changing hands: work items going to workers, results coming back, events being delivered, a signal that something finished. The channel gives you ordering, a place for backpressure and a clean way to end (close). Reach for a mutex when many goroutines read and update one shared structure, such as a cache or a counter, where there is no handoff to model. Reach for sync.WaitGroup or errgroup when the question is only "have they all finished". And reach for nothing when the work is sequential. Code that wraps every shared variable in a goroutine and a pair of channels, a guard goroutine per map, is usually a mutex written the hard way; the cost is a goroutine, two channels and a lot of code, for what sync.Mutex does in two lines.
The converse also holds. A mutex-protected queue that consumers poll in a loop with a sleep is a channel written badly, with latency and wasted wake-ups that a channel would avoid. The honest test is to describe what the code does in a sentence. If the sentence contains "passes", "hands over", "delivers" or "notifies", a channel probably fits. If it contains "protects", "updates" or "reads and writes", a lock probably does.
Rules to remember
- A send and a receive on an unbuffered channel complete together; on a buffered one, they complete when there is room or a value.
- Only the sender closes, once, normally with
defer. - A receive from a closed channel never blocks; use
v, okorrangeto tell data from closure. - A send on a closed channel panics; a close of a closed or nil channel panics.
- A nil channel blocks forever and disables its
selectcase. - Give functions directional types.
- Size buffers deliberately, and know what happens when they fill.
Common mistakes
Forgetting to close, so a range loop never ends and a goroutine leaks (goroutine leaks). Closing from the receiver, which races the sender into a panic. Using a buffered channel to paper over a deadlock, which defers the failure to production load. Treating the zero value from a closed channel as data. Sending to a channel nobody reads after an early return, the blocked sender from returning errors from goroutines.
Where to go next
Channels combine through select, covered next, and with context for cancellation. The patterns built from them, worker pools, pipelines and fan-out, are in worker pools and pipelines. For the language definition see the specification's sections on channel types and send statements, and for the original treatment read the Go blog's Share Memory By Communicating.


