Channels Explained
A channel is Go’s built-in mechanism for goroutines to send and receive values to and from each other, and it is the backbone of idiomatic Go concurrency. Instead of protecting shared variables with locks, Go encourages you to pass ownership of data through a channel so that at any moment only one goroutine is touching it. This lesson covers everything you need to use channels confidently: how they work under the hood, buffered versus unbuffered semantics, the select statement, closing and ranging over channels, and the mistakes that trip up almost every Go programmer at least once.
Overview: How Channels Work
Go’s concurrency model is often summarized as "do not communicate by sharing memory; share memory by communicating." A channel is a typed, first-class value that acts as a conduit: you declare one with chan T for some type T, and goroutines send values into it and receive values out of it. The type system enforces that only values of type T ever pass through, so channels are as type-safe as any other Go value.
Internally, the runtime represents a channel as a struct (the runtime calls it hchan) containing a circular buffer, the buffer’s capacity and current element count, and two wait queues: one for goroutines parked waiting to send, and one for goroutines parked waiting to receive. Access to this struct is protected by a mutex so that concurrent sends and receives from many goroutines stay correct. When you call make(chan T) with no second argument, the capacity is zero — this is an unbuffered channel. When you call make(chan T, n), the runtime allocates a ring buffer that can hold up to n elements — a buffered channel.
The distinction matters enormously in practice. An unbuffered channel is a rendezvous point: a send blocks until some goroutine is ready to receive that exact value, and a receive blocks until some goroutine is ready to send. This forces a synchronization point between the two goroutines — when the send completes, both sides know the handoff happened. A buffered channel decouples the two sides up to its capacity: a send only blocks once the buffer is full, and a receive only blocks when the buffer is empty. This makes buffered channels useful as bounded queues, but it also means a successful send no longer guarantees the receiver has seen the value yet — only that it landed in the buffer.
Channels can also be restricted to a direction in a function signature: chan<- T is a send-only channel, and <-chan T is a receive-only channel. A bidirectional chan T value converts implicitly to either restricted form when passed as an argument, but not the other way around, so the compiler catches a worker function that accidentally tries to receive on what should be its output channel. Finally, the zero value of a channel type is nil. Sending or receiving on a nil channel never panics — it blocks forever. That sounds useless, but it is a deliberate tool: inside a select statement, setting a channel variable to nil effectively disables that case, since a nil channel operation can never be the one that’s ready.
Syntax
The table below summarizes every channel operation you’ll use day to day.
| Operation | Meaning |
|---|---|
make(chan T) |
create an unbuffered channel of type T |
make(chan T, n) |
create a buffered channel with capacity n |
ch <- v |
send v on ch (blocks if unbuffered with no receiver, or buffer is full) |
v := <-ch |
receive a value from ch (blocks until one is available) |
v, ok := <-ch |
receive; ok is false only when ch is closed and drained |
close(ch) |
close ch; signals no more values will be sent |
for v := range ch |
receive repeatedly until ch is closed |
select { ... } |
wait on multiple channel operations at once |
ch := make(chan T) // unbuffered channel of type T
ch := make(chan T, n) // buffered channel with capacity n
ch <- v // send v on ch (may block)
v := <-ch // receive from ch (may block)
v, ok := <-ch // ok is false if ch is closed and drained
close(ch) // close ch; only the sender should do this
for v := range ch { // receive until ch is closed
// use v
}
select {
case v := <-ch1:
// ch1 had a value ready
case ch2 <- x:
// ch2 had room to accept x
default:
// neither was ready (non-blocking)
}
Examples
Example 1: A basic handoff with an unbuffered channel
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 anonymous goroutine tries to send a string on ch. Because ch is unbuffered, that send blocks until main reaches <-ch. Once both sides are ready, the runtime hands the value directly from one goroutine to the other and both continue running. Without the go keyword this would deadlock, because a single goroutine can’t simultaneously send and wait to receive on the same unbuffered channel.
Example 2: A buffered channel, closing, and range
package main
import "fmt"
func main() {
ch := make(chan int, 3)
ch <- 1
ch <- 2
ch <- 3
close(ch)
for v := range ch {
fmt.Println(v)
}
}
Output:
1
2
3
Because the channel has capacity 3, all three sends succeed immediately without a receiver on the other end — they simply fill the ring buffer. Calling close(ch) does not discard the buffered values; a range loop (or a plain receive) can still drain them. Once the buffer is empty and the channel is closed, the range loop exits automatically instead of blocking forever.
Example 3: A worker pool
package main
import (
"fmt"
"sync"
)
func worker(jobs <-chan int, results chan<- int, wg *sync.WaitGroup) {
defer wg.Done()
for j := range jobs {
results <- j * j
}
}
func main() {
jobs := make(chan int, 5)
results := make(chan int, 5)
var wg sync.WaitGroup
for w := 1; w <= 3; w++ {
wg.Add(1)
go worker(jobs, results, &wg)
}
for j := 1; j <= 5; j++ {
jobs <- j
}
close(jobs)
wg.Wait()
close(results)
sum := 0
for r := range results {
sum += r
}
fmt.Println("sum of squares:", sum)
}
Output:
sum of squares: 55
This is the classic fan-out/fan-in pattern. Three worker goroutines all range over the same jobs channel, so Go’s runtime distributes the five jobs among whichever workers are free — each job is delivered to exactly one worker. Closing jobs after all sends tells every worker’s range loop to exit once the buffer is drained. The sync.WaitGroup lets main know when every worker has finished, which is the safe moment to close results; closing it any earlier could cause a send on a closed channel and a panic. Notice the parameter types: jobs <-chan int can only be received from, and results chan<- int can only be sent to — the compiler enforces that a worker can’t accidentally write to jobs or read from results.
Example 4: select with a timeout
package main
import (
"fmt"
"time"
)
func main() {
ch := make(chan string)
go func() {
time.Sleep(100 * time.Millisecond)
ch <- "data ready"
}()
select {
case msg := <-ch:
fmt.Println(msg)
case <-time.After(500 * time.Millisecond):
fmt.Println("timeout")
}
}
Output:
data ready
select waits on several channel operations at once and runs the branch of whichever one becomes ready first. Here the goroutine sends after 100ms, well before the 500ms timeout fires, so the first case wins and "data ready" is printed. time.After returns a channel that receives a value once the given duration elapses, which is the standard idiom for putting a timeout on a blocking channel operation. If the sending goroutine were slower than 500ms, the timeout case would fire instead.
How It Works Step by Step
Understanding what happens under the hood makes channel behavior predictable instead of mysterious. Consider Example 1:
1. make(chan string) asks the runtime to allocate an hchan struct on the heap with capacity zero, and ch holds a pointer to it.
2. The go statement creates a new goroutine (internally a g struct) and marks it runnable. Go’s scheduler multiplexes many goroutines onto a smaller number of OS threads using the GMP model (Goroutines, Machine/OS threads, Processors) — the new goroutine may start running immediately on another thread, or it may simply be queued, depending on what’s free.
3. Whichever side reaches its channel operation first has to wait. Say main reaches <-ch before the goroutine sends. The runtime wraps the waiting goroutine in a small struct called a sudog, links it into the channel’s receive wait queue, and calls into the scheduler to park it — the goroutine stops consuming CPU entirely and the underlying OS thread is freed to run other work.
4. When the other goroutine executes ch <- "hello from goroutine", the runtime finds the parked receiver directly in the wait queue, copies the string straight into the receiver’s stack (no intermediate buffer needed for an unbuffered channel), and calls goready to mark the receiving goroutine runnable again. The scheduler picks it up on the next available thread.
5. For a buffered channel, the same wait-queue mechanism applies only when the buffer is full (for senders) or empty (for receivers); otherwise a send just writes into the next ring-buffer slot and returns immediately without ever touching the scheduler.
6. close(ch) sets a closed flag on the hchan and wakes every goroutine currently parked in either wait queue — parked receivers wake up with the zero value and ok == false, while parked senders wake up mid-panic, because sending on a closed channel is illegal.
Common Mistakes
Mistake 1: Deadlock from an unbuffered channel with no receiver
package main
import "fmt"
func main() {
ch := make(chan int)
ch <- 1 // no other goroutine is receiving: this blocks forever
fmt.Println(<-ch)
}
This compiles fine but never finishes running. ch <- 1 blocks because nothing is receiving concurrently, and since it’s the only goroutine, nothing ever will — the Go runtime detects this and crashes with fatal error: all goroutines are asleep - deadlock! The fix is to either give the channel enough buffer for the send to not need a waiting receiver, or to move the send into its own goroutine:
package main
import "fmt"
func main() {
ch := make(chan int, 1) // buffered: the send below doesn't need a waiting receiver
ch <- 1
fmt.Println(<-ch)
}
Output:
1
Mistake 2: Sending on a closed channel panics
package main
import "fmt"
func main() {
ch := make(chan int, 1)
close(ch)
ch <- 1 // panic: send on closed channel
fmt.Println("unreachable")
}
Once a channel is closed, any further send — from any goroutine — panics immediately, and closing an already-closed channel also panics. This is why only the sender should ever call close, and only after it is certain no more sends will happen (as in the worker pool example, where close is called only after wg.Wait() confirms every sender is done). Receiving from a closed channel, by contrast, is always safe:
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
The first receive drains the buffered value and reports ok == true. The second receive happens after the channel is both closed and empty, so it returns the zero value for the element type and ok == false instead of blocking — this is exactly the signal a range loop checks internally to know when to stop.
Mistake 3: Capturing the loop variable in a goroutine
package main
import "fmt"
func main() {
ch := make(chan int, 5)
for i := 0; i < 5; i++ {
go func() {
ch <- i // bug on Go before 1.22: goroutines may all read the same i
}()
}
for j := 0; j < 5; j++ {
fmt.Println(<-ch)
}
}
Before Go 1.22, every iteration of a for loop reused the same i variable, so closures created inside the loop all captured a reference to that single shared variable rather than a snapshot of its value at each iteration. By the time the goroutines actually ran, i could already equal 5, or several goroutines could send the same value. Go 1.22 changed loops to give each iteration its own copy of i, which fixes this specific case going forward — but passing the value as an explicit parameter remains the clearer, version-independent fix, and it’s still the idiom you’ll see in most Go code:
package main
import "fmt"
func main() {
ch := make(chan int, 5)
for i := 0; i < 5; i++ {
go func(n int) {
ch <- n * n
}(i)
}
sum := 0
for j := 0; j < 5; j++ {
sum += <-ch
}
fmt.Println("sum:", sum)
}
Output:
sum: 30
Passing i as the argument n copies its current value into the goroutine’s own stack frame at the moment go is called, so each goroutine squares the value it was meant to, regardless of how the shared loop variable changes afterward. Summing the results sidesteps the remaining question of which goroutine finishes first, since addition doesn’t care about order.
Best Practices
- Prefer unbuffered channels by default — they force a clear synchronization point. Reach for a buffered channel only when you have a specific reason, such as a known pipeline size or limiting concurrency.
- Only the sender should ever call
closeon a channel, and only once every send is guaranteed to be finished; never close a channel from the receiving side. - If multiple goroutines might send on the same channel, don’t close it directly from any of them — coordinate with a
sync.WaitGroup(as in the worker pool example) soclosehappens exactly once, after all senders finish. - Use
selectwith adefaultcase when you need a non-blocking check of a channel instead of waiting. - Prefer
context.Contextfor cancellation in APIs that other code will call, rather than inventing an ad hoc "done" channel for every function. - Every goroutine you start should have a clear way to finish — a closed channel, a cancelled context, or a
WaitGroup. An unbounded goroutine leak is one of the most common production bugs in Go. - Don’t reach for a channel just to protect a shared counter or map — a
sync.Mutexis simpler and usually faster for pure mutual exclusion. Save channels for passing data or signaling between goroutines. - A buffered channel of size 1 is a common trick to prevent a goroutine leak when exactly one value will ever be sent and the receiver might not be ready yet (for example, a result that the caller might abandon).
Practice Exercises
- Spawn five goroutines, each computing the cube of its own index (0 through 4) and sending the result on a shared channel. Receive all five values in
mainand print their sum. (Expected sum: 100.) - Build a two-stage pipeline: a goroutine that sends the integers 1 through 10 on a channel, and a second goroutine that receives each one, doubles it, and sends it on a second channel. Have
mainrange over the second channel and print every value. - Write a function that receives from a channel but gives up if nothing arrives within 200 milliseconds, using
selectandtime.After. Test it against a sender that sleeps for 50ms (should succeed) and one that sleeps for 300ms (should report a timeout).
Summary
- A channel is a typed conduit that lets goroutines communicate instead of sharing memory directly; it’s created with
make(chan T)ormake(chan T, n)for a buffered version. - Unbuffered channels synchronize sender and receiver at a rendezvous point; buffered channels decouple them up to their capacity.
- Directional channel types,
chan<- Tand<-chan T, document and enforce intent at compile time. - A
nilchannel blocks forever on send or receive, which is useful for disabling aselectcase. - Only the sender should
closea channel; receiving from a closed channel is always safe and returns the zero value withok == falseonce it’s drained. selectlets a goroutine wait on multiple channel operations and proceed with whichever is ready first, optionally with adefaultfor non-blocking behavior or atime.Aftercase for a timeout.- Always have a plan for how every goroutine you launch will terminate, to avoid leaks.
