So, you've written your first goroutine, you've sent a value down a channel, and Go did exactly what you expected. Beautiful. Channels feel like one of those things that just clicks.
Then you ship something with them.
A worker hangs because nobody's reading from the result channel. A close() lands on the wrong side and your service panics under load. A buffered channel "fixes" a deadlock in your tests, then deadlocks anyway in production once traffic shifts. A goroutine quietly leaks for two weeks until your memory chart starts looking like a hockey stick.
That's the gap this article tries to close — between "channels work in my hello-world" and "channels are running in production at 2am and not paging me."
Channels aren't magic. They're a synchronization primitive with very specific rules. Once you know those rules, the patterns get boring in the best possible way.
We're going to walk through:
- the actual mental model (it's not "Go's version of a queue"),
- buffered vs unbuffered, and what the buffer really means,
- who's allowed to close a channel (this one trips up everybody),
- a real worker pool that doesn't panic,
select,context, and how cancellation actually works,- the five mistakes I keep seeing in real codebases.
By the end, you should be able to look at a channel in a code review and answer the only three questions that matter: who sends, who receives, who closes.
The mental model: a channel is a handshake, not a queue
Most people learn channels and think "oh, like a queue." That intuition will bite you.
A channel is a typed conduit between goroutines. Yes, values flow through it. But more importantly, the send and receive operations synchronize the two goroutines with each other. When you send on an unbuffered channel, your goroutine literally pauses until somebody receives. That pause is the point.
Here's the smallest example that captures it:
ch := make(chan string)
go func() {
ch <- "file processed"
}()
msg := <-ch
fmt.Println(msg)
Two goroutines, one moment of contact. The sender can't move on until the receiver shows up. The receiver can't move on until somebody sends. They meet at the channel, hand the value over, and continue.
Hold on to that picture: a handshake, not a mailbox. Most channel bugs become obvious the moment you ask "who's on the other side of this handshake, and can they actually be there?"
Unbuffered channels: a strict handshake
An unbuffered channel is the strictest, simplest version of the primitive.
done := make(chan struct{})
go func() {
fmt.Println("worker: finished")
done <- struct{}{}
}()
<-done
fmt.Println("main: observed completion")
main can't reach "observed completion" until the worker sends. The worker can't finish its send until main receives. That's the contract. Tight, predictable, easy to reason about.
chan struct{} is the idiomatic "I just want to signal something, no payload" type. The empty struct uses zero bytes, which is a tiny but satisfying detail.
Pro Tip: an unbuffered channel is great for one-off signals. The moment you find yourself making a separate signal channel for every little coordination, you've reinvented
sync.WaitGroup— only worse. Use the primitive that names the thing you actually mean.
Speaking of which:
var wg sync.WaitGroup
for i := 1; i <= 3; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
fmt.Println("worker", id)
}(i)
}
wg.Wait()
fmt.Println("all workers finished")
When the question is "wait for this group of goroutines," WaitGroup says exactly that. A channel can do it too, but it'll read like a riddle in three months.
Note on Go 1.22+: the
i := ishadow trick at the top of the loop body is no longer required — each iteration gets its owni. If you're on an older version, you still need it (or passias an argument like the example above does).
Buffered channels: a queue with a budget
A buffered channel adds capacity. Sends don't block until the buffer is full; receives don't block until it's empty.
jobs := make(chan int, 100)
That 100 is not a tuning knob. It's a design decision. You're saying: "Producers can get up to 100 jobs ahead before they have to slow down and wait for a worker." That's backpressure, and it's usually exactly what you want.
The mistake is treating the buffer as a clever way to avoid deadlocks. This program deadlocks:
func main() {
ch := make(chan int)
ch <- 1
fmt.Println(<-ch)
}
There's only one goroutine. It sends, then tries to receive — but there's nobody else around to do the other half of the handshake. So it sits there, forever, while Go's runtime detects the situation and crashes the program.
Adding a buffer of 1 "fixes" it:
ch := make(chan int, 1)
ch <- 1
fmt.Println(<-ch)
But the design was wrong. You didn't fix the lifecycle, you just gave it room to breathe for one message. In the real world, message #101 will hit the same wall and you'll be debugging at midnight wondering why the smaller test passes.
A useful rule of thumb: if you can't justify the buffer size with a sentence about producer/consumer behavior, set it to 0 and figure out what's actually missing.
Closing channels: only the sender hangs up
close(ch) doesn't destroy the channel. It says "no more values are coming." Receivers can still drain whatever's left, and after that they get the zero value with ok == false.
The clean pattern looks like this:
ch := make(chan int)
go func() {
defer close(ch)
for i := 1; i <= 3; i++ {
ch <- i
}
}()
for value := range ch {
fmt.Println(value)
}
for range keeps reading until the channel is both closed and drained. It's the cleanest receiver loop in Go.
Now, the rule that wrecks half of everyone's first concurrent service:
The sender closes. Never the receiver. Never another worker.
If you let a receiver call close, it has no idea whether someone else is still about to send — and sending on a closed channel is a panic, not an error:
ch := make(chan int)
close(ch)
ch <- 1 // panic: send on closed channel
When multiple goroutines all send on the same channel, no individual sender knows when the others are done. So you need a coordinator: one goroutine that watches all the senders, waits for them to finish (usually with sync.WaitGroup), and then closes. We'll see that pattern in a second.
When the zero value is meaningful, use the two-value receive
If 0, "", or nil are valid values your channel might carry, you can't tell "got data" from "channel closed" by looking at the value alone. Use the comma-ok form:
value, ok := <-ch
if !ok {
fmt.Println("channel closed")
return
}
fmt.Println("received:", value)
This is one of those tiny patterns that costs nothing to type and saves you a class of bugs.
A worker pool that doesn't panic
This is where the closing rules pay off. A real worker pool has three moving parts:
- A producer that owns the
jobschannel and closes it when done. - N workers that read from
jobsand write toresults. Workers never close anything. - A coordinator that waits for workers to finish, then closes
results.
package main
import (
"fmt"
"sync"
)
type Job struct {
ID int
}
type Result struct {
JobID int
Value int
}
func worker(id int, jobs <-chan Job, results chan<- Result, wg *sync.WaitGroup) {
defer wg.Done()
for job := range jobs {
results <- Result{JobID: job.ID, Value: job.ID * 2}
}
}
func main() {
jobs := make(chan Job)
results := make(chan Result)
var wg sync.WaitGroup
for workerID := 1; workerID <= 3; workerID++ {
wg.Add(1)
go worker(workerID, jobs, results, &wg)
}
// Producer: owns jobs, closes when done sending.
go func() {
defer close(jobs)
for i := 1; i <= 5; i++ {
jobs <- Job{ID: i}
}
}()
// Coordinator: closes results once every worker has stopped.
go func() {
wg.Wait()
close(results)
}()
for result := range results {
fmt.Printf("job=%d value=%d\n", result.JobID, result.Value)
}
}
Stare at this for a minute. Every channel has exactly one goroutine that closes it. The workers can't close results because there are three of them, so a separate "drain coordinator" exists for the sole purpose of closing it once the WaitGroup clears. The main goroutine just consumes.
This is the boring pattern. Boring is exactly what you want here.
Directional channels: a free readability upgrade
Look at that worker signature again:
func worker(id int, jobs <-chan Job, results chan<- Result, wg *sync.WaitGroup)
jobs <-chan Job means "I can only receive from this." results chan<- Result means "I can only send to this." If somebody accidentally writes close(jobs) inside the worker, the compiler shouts at them before the bug ever happens.
Directional types don't change runtime behavior. They're documentation that the compiler checks for you. Always narrow channel parameters to the direction you actually use. It's a five-character change that catches real mistakes.
Deadlocks are almost always lifecycle bugs
If you stare at enough deadlocks, a pattern shows up. They're rarely about Go itself — they're almost always one of these:
- A goroutine sends, but nobody's listening.
- A goroutine receives, but nobody's sending.
- A
for rangewaits on a channel that nobody ever closes. - A sender keeps sending after the receiver bailed early.
- Two goroutines wait on each other in a perfect circle.
Quick example of the "forgot to close" case:
func main() {
ch := make(chan int, 2)
ch <- 1
ch <- 2
for value := range ch {
fmt.Println(value)
}
}
The buffer accepts both sends. The loop prints 1 and 2. Then it waits forever for a third value that never comes — because nobody ever closed ch. One line fix:
ch <- 1
ch <- 2
close(ch)
The trickier case is "receiver exits early":
func firstValue(in <-chan int) int {
return <-in
}
If whoever feeds in is sending many values, this function takes one and walks away. The sender then blocks on its second send, forever. That's how a perfectly innocent helper function leaks goroutines.
Which is the perfect setup for the next section.
Cancellation: stop pretending goroutines clean up after themselves
Backend services are full of cancellation. Requests time out. Clients close their tabs. Load balancers drop connections. Pods get terminated mid-flight. If your goroutines don't notice any of that, they leak — and goroutine leaks compound, because new requests keep starting fresh ones.
The standard tool here is context.Context. You've seen it in every Go function signature. There's a reason.
Here's a generator that actually respects cancellation:
func generate(ctx context.Context) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for i := 1; ; i++ {
select {
case <-ctx.Done():
return
case out <- i:
}
}
}()
return out
}
The producer keeps generating numbers. But before every send, it asks: "did somebody cancel?" If yes, it exits and closes out on the way out the door. No leak.
The consumer side cancels the moment it's done:
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
for value := range generate(ctx) {
fmt.Println(value)
if value == 3 {
cancel()
break
}
}
Without that cancel(), the generator would keep going until the deferred cancel fires when main returns — fine in this toy, deadly in a long-running service.
Pro Tip: every blocking send and every blocking receive in a long-lived goroutine deserves a
selectwith a<-ctx.Done()case. If you can't add one, that's a signal the goroutine doesn't know how to stop, which means it's a leak waiting to happen.
select: the real channel superpower
select is what lifts channels from "neat primitive" to "design tool." It lets a goroutine wait on multiple channel operations at once and proceed with whichever one is ready first.
select {
case value := <-results:
fmt.Println("result:", value)
case err := <-errs:
fmt.Println("error:", err)
case <-ctx.Done():
fmt.Println("canceled:", ctx.Err())
}
Three possible futures, one statement. If multiple cases are ready at the same instant, Go picks one at random — which is a feature, not a bug. It prevents a busy channel from starving the others.
For the "wait for a result, an error, or cancellation" pattern, two channels feels natural at first:
func waitForResponse(ctx context.Context, responses <-chan Response, errs <-chan error) (Response, error) {
select {
case response := <-responses:
return response, nil
case err := <-errs:
return Response{}, err
case <-ctx.Done():
return Response{}, ctx.Err()
}
}
It works, but as soon as you have multiple producers, you start asking awkward questions: can both channels fire? Which closes first? What if an error arrives after a value?
Most of the time, one channel carrying both value and error is simpler:
type Result struct {
Response Response
Err error
}
func waitForResult(ctx context.Context, results <-chan Result) (Response, error) {
select {
case result, ok := <-results:
if !ok {
return Response{}, fmt.Errorf("results channel closed")
}
return result.Response, result.Err
case <-ctx.Done():
return Response{}, ctx.Err()
}
}
A success and a failure can't race each other on separate channels because they're carried by the same message. One less category of bugs.
Nil channels: useless on their own, secretly powerful in select
A nil channel blocks forever. Always.
var ch chan int
// <-ch blocks forever
// ch <- 1 blocks forever
You almost never want this... except inside select. A nil channel case in a select is never selected. Which means you can use nil as a switch to disable a case once you're done with that channel.
The classic example is merging two streams into one and stopping when both finish:
func merge(a, b <-chan int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for a != nil || b != nil {
select {
case value, ok := <-a:
if !ok {
a = nil // disable this case
continue
}
out <- value
case value, ok := <-b:
if !ok {
b = nil
continue
}
out <- value
}
}
}()
return out
}
When a closes, we set it to nil. The case on a is now dead. The loop keeps running until b closes too, then exits. Elegant, no extra flags, no extra synchronization.
This is the kind of pattern that's worth knowing exists, but you should be honest with yourself before using it: will the next person reading this code understand it in ten seconds, or thirty minutes? Sometimes a slightly more verbose version with a boolean is the kinder choice.
Channels vs mutexes: pick the model, not the meme
A common piece of Go folklore goes: "Don't communicate by sharing memory; share memory by communicating." It's a great slogan. It's also been quoted to death by people who then write channel-based shared counters.
Channels are the right tool when communication is the model — you have producers, consumers, pipelines, ownership transfer, fan-out/fan-in.
Mutexes are the right tool when shared protected state is the model — you have a thing, and a few goroutines update it.
A counter is the second case:
type Counter struct {
mu sync.Mutex
value int
}
func (c *Counter) Inc() {
c.mu.Lock()
defer c.mu.Unlock()
c.value++
}
func (c *Counter) Value() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.value
}
No goroutine lifecycle. No "who closes this." No background loop. Just a small struct that's safe for everyone to call. That's not a failure of channels — it's choosing the right primitive.
The five mistakes I keep seeing
After enough code reviews, the same handful of mistakes shows up over and over. Here's the greatest hits.
1. Closing from the receiver side
func consume(ch chan int) {
value := <-ch
fmt.Println(value)
close(ch) // wrong
}
The receiver doesn't know who else is sending. Don't close from here. The fix is usually: take a <-chan int instead, and let the producer close it.
2. Spawning goroutines without a stop path
func startLogger(messages <-chan string) {
go func() {
for msg := range messages {
fmt.Println(msg)
}
}()
}
This goroutine only stops when messages closes. If the caller never closes it, the goroutine lives forever. Pass a context:
func startLogger(ctx context.Context, messages <-chan string) {
go func() {
for {
select {
case <-ctx.Done():
return
case msg, ok := <-messages:
if !ok {
return
}
fmt.Println(msg)
}
}
}()
}
Now there are two ways out: the channel closes, or the context cancels. Either one is fine.
3. Ignoring the ok from a receive
value := <-numbers
fmt.Println(value)
If numbers is closed, value is 0. Maybe that's a real number. Maybe it's the closed channel screaming at you. You can't tell. Use the two-value form whenever the zero value could be ambiguous.
4. Two channels for value + error
values := make(chan string)
errs := make(chan error)
Now you've signed up for a small list of awkward questions: who closes which one, in what order, what if both fire, who drains the loser. Most of the time, one channel of struct { Value; Err } makes the answers obvious.
5. "I'll add a buffer to fix the deadlock"
A buffer doesn't fix a missing receiver. It just delays meeting it. If you don't know who's on the other side of the channel, no buffer size will save you — and a bigger buffer just hides the bug deeper into production.
A 60-second checklist before you reach for a channel
Every time I'm about to write make(chan ...) in a code review, I run through these:
- What type of value flows through it?
- Is it buffered or unbuffered, and can I justify that in one sentence?
- Who sends?
- Who receives?
- Who closes it?
- Can there be multiple senders? If so, who's the coordinator?
- What happens if the receiver bails early?
- What happens when the context is canceled?
- Would a
sync.WaitGroup, a mutex, or a plain function call be simpler?
If any of those answers are fuzzy, the design isn't ready yet. That's fine. Sketch the lifecycle on a napkin first, then write the channel.
Wrapping it all up
Channels are wonderful when you remember what they actually are: a typed handshake that synchronizes two goroutines and optionally moves a value between them. Almost every channel bug I've ever debugged was really a lifecycle bug in disguise — somebody wasn't sure who sends, who receives, or who closes.
Keep the rules close:
- Senders close, receivers don't.
for rangeuntil the sender hangs up.value, ok := <-chwhenever the zero value is ambiguous.context.Contextfor cancellation, every time.- One result channel of
{Value, Err}beats two channels in a race. - Channels for communication, mutexes for state. Pick the one that names your problem.
And one last thing — the temptation to reach for a channel just because Go has them is real. Resist it. Go's most boring code is its best code, and a function that returns a value and an error directly is almost always a better answer than the equivalent channel pipeline.
Channels are a tool. Use them when communication is the design, not before. Now go write something concurrent — and may your race detector stay quiet. 👊





