The pass-through window
A busy restaurant kitchen runs on a narrow pass-through window between the chefs and the runners. The chef drops a plate on the counter. The runner waits until a plate appears, picks it up, and walks it to a table. They never shout across the room. They never reach into each other's workspace. The window forces a clear handoff. One side produces, the other side consumes, and the counter itself enforces the timing.
Go channels work the same way. They are typed pipes that connect independent goroutines. You send a value into one end and receive it from the other. The channel handles the synchronization. You do not need mutexes or atomic variables to protect shared state. You just pass messages.
How channels actually work
A channel is a reference type. You create it with make, and it carries a type signature like chan int or chan string. The type tells the compiler exactly what can flow through it. Trying to send a float into an integer channel fails at compile time.
Channels come in two flavors. An unbuffered channel has zero capacity. It requires a sender and a receiver to meet at the exact same moment. The send blocks until a receive is ready. The receive blocks until a send happens. This is a synchronous handshake.
A buffered channel holds a fixed number of values. You specify the capacity in make. The sender can push values into the buffer without waiting for a receiver, as long as space remains. Once the buffer fills, the sender blocks until someone drains a slot. The buffer acts like a drop box. It decouples the timing of production and consumption.
Both flavors guarantee that only one goroutine reads or writes a specific value at a time. The runtime serializes access automatically. You get thread safety without writing lock logic.
Minimal example: the handshake
Here is the simplest unbuffered channel: spawn one goroutine, send a value, receive it in the main function, and print the result.
package main
import (
"fmt"
)
func main() {
// unbuffered channel forces a synchronous handoff
messages := make(chan int)
// background goroutine prepares the value
go func() {
// blocks until main is ready to receive
messages <- 42
}()
// blocks until the goroutine pushes a value
received := <-messages
fmt.Println("Received:", received)
}
The arrow points in the direction of data flow. ch <- val sends. val := <-ch receives. The operator is the same; the position tells the compiler what you are doing.
Goroutines are cheap. Channels are not magic.
What happens under the hood
When the program starts, the main goroutine calls make(chan int). The runtime allocates a small control structure on the heap. It tracks the queue, the buffer size, and pointers to waiting goroutines. The variable messages holds a reference to that structure, not the data itself.
The main function launches a background goroutine. The scheduler puts it in a ready queue. The background goroutine immediately hits messages <- 42. The channel has no buffer and no receiver waiting. The runtime parks the goroutine and marks it as blocked on that channel.
Main continues to received := <-messages. It checks the channel. No value is available. The runtime parks main and marks it as blocked on the same channel.
Now both sides are waiting. The scheduler notices a sender and a receiver are blocked on the same channel. It wakes them up, copies the integer from the sender's stack to the receiver's stack, and unblocks both. The value transfers safely. No memory is shared. No lock is acquired. The runtime handles the bookkeeping.
This blocking behavior is intentional. It prevents race conditions by design. If you need to coordinate work without passing data, you can send a zero-size struct. struct{} takes zero bytes but still triggers the handshake.
Realistic example: producer and consumer
Real programs rarely send a single value. They stream data. A producer generates items, a consumer processes them, and the channel bridges the gap. Buffered channels shine here because they absorb bursts of production without stalling the sender.
Here is a producer that emits three strings, closes the channel, and a consumer that drains it with a range loop.
package main
import (
"fmt"
)
func main() {
// buffer of 2 lets the producer send twice without blocking
stream := make(chan string, 2)
// producer runs in the background
go func() {
// first send fits in the buffer immediately
stream <- "alpha"
// second send also fits, buffer is now full
stream <- "beta"
// third send blocks until the consumer reads one
stream <- "gamma"
// signals that no more values will arrive
close(stream)
}()
// consumer reads until the channel is closed and empty
for msg := range stream {
fmt.Println("Processing:", msg)
}
}
The range loop is the idiomatic way to drain a channel. It repeatedly receives values until the channel is both closed and empty. When that happens, the loop exits cleanly. You do not need a break statement. You do not need to track a counter.
Closing a channel is a one-way signal. It tells every receiver that the producer is finished. Receivers can still read values that are already in the buffer. Once the buffer drains, subsequent receives return the zero value for the channel type and a boolean false. This is the comma-ok idiom: val, ok := <-ch. If ok is false, the channel is closed and empty.
Never close a channel from the receiver side. Only the sender should call close. Closing a channel while another goroutine is still trying to send triggers a runtime panic. The runtime enforces this rule to prevent silent data corruption.
Context is plumbing. Run it through every long-lived call site.
Pitfalls and runtime panics
Channels are simple, but they have sharp edges. The most common mistake is forgetting to close a channel that feeds a range loop. The consumer will block forever, and the goroutine running the loop will leak. Goroutine leaks happen when the goroutine waits on a channel that never gets closed. Always have a cancellation path.
Another frequent error is closing a channel twice. The first close works. The second call panics with close of closed channel. The runtime does not recover from this. You must track whether a channel is already closed, or better, design your program so only one goroutine ever calls close.
Directional channels help the compiler catch mistakes early. You can restrict a channel to send-only (chan<- int) or receive-only (<-chan int). If you pass a send-only channel to a function that tries to read from it, the compiler rejects the program with invalid operation: receive from send-only channel. Directional channels are a form of documentation that the type system enforces.
The comma-ok idiom is useful when you need to distinguish between a legitimate zero value and a closed channel. If your channel carries integers, receiving 0 is valid data. val, ok := <-ch returns ok == false only when the channel is closed and empty. Using _ to discard the boolean is fine when you do not care about the distinction, but use it sparingly with errors and channel states.
The worst goroutine bug is the one that never logs.
When to reach for channels
Channels are not a replacement for every synchronization primitive. They excel at message passing and coordination. Pick the right tool based on the shape of your problem.
Use an unbuffered channel when you need strict synchronization between two goroutines and want to guarantee that a value is consumed immediately after production. Use a buffered channel when you want to decouple producer and consumer timing or absorb short bursts of work without blocking the sender. Use a directional channel when you want to restrict a function's access to a pipe and let the compiler enforce the boundary. Use a zero-size struct channel when you need a signal or a done flag without transferring actual data. Use a sync.WaitGroup when you only need to wait for a set of goroutines to finish and do not need to pass results back. Use a sync.Mutex when multiple goroutines need to read and write the same shared variable and message passing would add unnecessary indirection. Use plain sequential code when you do not need concurrency: the simplest thing that works is usually the right thing.
Accept interfaces, return structs. Trust gofmt. Argue logic, not formatting.