Skip to content

Channels: what they are and what they're for

Updated:

The problem channels solve

When you start a goroutine to do some work, you hit a problem straight away. It can do the work perfectly well, but it has no way of giving you the answer.

go compute() // whatever compute returns, it's gone

go f() launches f and moves straight on to the next statement. By the time f finishes, the line that started it is long past, so there is nowhere for a return value to go. A goroutine has no caller waiting to receive one. Go won’t even let you pretend otherwise: x := go compute() doesn’t compile.

So how do you get a value out of a goroutine?

The obvious answer is to have it write somewhere you can both see. Say you have a batch of jobs to get through, and you split them across two workers. You’d like to know how many got done, so both workers bump a counter.

Here’s the version everyone writes first:

package main

import (
	"flag"
	"fmt"
	"sync"
)

// stands in for the real work: validate a record, resize an image, parse a line
func process(job int) {
	_ = job * job
}

func main() {
	n := flag.Int("n", 10, "how many jobs to process")
	flag.Parse()

	jobs := make([]int, *n)
	for i := range jobs {
		jobs[i] = i + 1
	}

	var processed int // shared by both workers

	var wg sync.WaitGroup
	wg.Add(2)

	go func() {
		defer wg.Done()
		for _, job := range jobs[:*n/2] {
			process(job)
			processed++ // 🚩
		}
	}()

	go func() {
		defer wg.Done()
		for _, job := range jobs[*n/2:] {
			process(job)
			processed++ // 🚩
		}
	}()

	wg.Wait()
	fmt.Printf("processed: %d (want %d)\n", processed, *n)
}

Run it and you’ll get 10. Run it enough times, or with enough jobs, and eventually you won’t, because nothing in this program guarantees that number. Run it with Go’s race detector and you get told exactly why:

$ go run -race ./01-race source
$ go run -race ./01-race
==================
WARNING: DATA RACE
Read at 0x00c0000181c8 by goroutine 9:
  main.main.func2()
      /home/you/code/01-race/main.go:51 +0x13d

Previous write at 0x00c0000181c8 by goroutine 8:
  main.main.func1()
      /home/you/code/01-race/main.go:43 +0x11a
==================
processed: 10 (want 10)
(correct this time, which is exactly why races are dangerous)
Found 1 data race(s)
exit status 66

Go’s race detector reports a read racing with a previous write, at the same address, from two different goroutines. Notice that the count still came out at 10. The detector flags the unsafe access pattern rather than a wrong answer, which means it finds the bug on the run where you got lucky. That is what makes it worth using.

Scale it up with -n and the wrongness becomes visible without any tooling:

$ go run ./01-race -n 1000000
processed: 549740 (want 1000000)
^ lost updates: this is what a data race looks like from the outside

Nearly half the jobs it finished were never counted.

The reason is that processed++ isn’t one step. It reads processed, adds one, and writes the result back. Two workers can read the same old value, each add one, and each write back, and one of those writes lands on top of the other. The increment that got overwritten is simply gone. The work itself still happened, which is the nasty part: only the count is wrong, and that is the kind of bug that survives code review.

One lost update. Both workers read 41 before either writes, so two increments happen and only one survives.

That is a data race: two goroutines touching the same memory at the same time, with at least one of them writing. Races are the reason concurrent code has the reputation it does, and they have a habit of turning up once a week in production and never once on your laptop.

There are two ways out. One is to lock the variable so a single goroutine touches it at a time, which is what sync.Mutex and sync/atomic are there for. The other is to stop sharing it: give each worker its own count, and have it send the number back when it has finished.

For a counter, take the lock. atomic.Int64 has an Add that compiles to a single instruction, and routing two numbers through a channel to add them up would be overkill. The counter earns its place here by being the shortest program that goes visibly wrong.

Sending is the option that keeps working as the problem grows. A goroutine that can hand back a finished number can hand back anything else it produced, a parsed row or an error just as easily. That is the handoff channels are built for.


What a channel is

A channel is a typed pipe between goroutines. One goroutine puts values in, another takes them out.

ch := make(chan int) // a channel that carries ints
ch <- 42             // send: put 42 in
v := <-ch            // receive: take a value out

The arrow always points in the direction the data moves. ch <- 42 pushes into the channel, and <-ch pulls out of it. That is the whole syntax, and once you read it as an arrow it stops being cryptic.

chan int is a real type, like []string or map[string]int. A channel of strings won’t accept an int, and the compiler stops you.

Here’s the same batch of jobs, rewritten:

package main

import "fmt"

func main() {
	jobs := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}

	results := make(chan int)

	go func() {
		mine := 0 // private to this worker
		for _, job := range jobs[:5] {
			process(job)
			mine++
		}
		results <- mine // hand the finished count over
	}()

	go func() {
		mine := 0 // private to this worker
		for _, job := range jobs[5:] {
			process(job)
			mine++
		}
		results <- mine
	}()

	first := <-results
	second := <-results

	fmt.Println("processed:", first+second) // always 10
}

Run this a million times with -race and it stays clean. Nothing is shared. Each worker owns its own mine, and the only thing that crosses between goroutines is a finished value handed over deliberately. That crossing answers the question we started with: the result got out of the goroutine, and it never lived in memory two goroutines could reach at once.

Compare it to the earlier diagram. That one had a shared cell in the middle that both workers read and wrote. This one has no middle lifeline at all, because there is no longer anything in the middle to share:

No shared cell, so no lifeline between the workers. Each count stays private until it is finished, and main waits until a completed value arrives.

Notice that the WaitGroup is gone too. <-results blocks until a value arrives, so once we have received two values, both workers have finished their jobs. The channel did the waiting for us, which brings us to the part that actually matters.


Channels block by design

One rule sits underneath all of this. Once it clicks, most of what channels do stops being surprising, including the parts that look like limitations:

On an unbuffered channel, a send blocks until another goroutine receives, and a receive blocks until another goroutine sends.

”Blocks” means the goroutine stops and waits. It isn’t spinning or burning CPU: the runtime parks it until the other side shows up, and wakes it then.

The word “pipe” suggests storage, which is why this catches people out. An unbuffered channel has no storage at all. It is a meeting point rather than a container you drop things into.

Unbuffered: the send and the receive are one event. Whoever arrives first waits. Here, that's the sender.

The everyday version: an unbuffered channel is handing someone a package in person. You both have to be there. Whoever arrives first waits for the other, the handoff happens at one instant, and afterwards you both walk away.

You can watch it happen. This program prints timestamps:

func receiverWaits() {
	ch := make(chan string)
	at := stopwatch() // returns [ 2.0s] style timestamps, counting from now

	go func() {
		fmt.Println(at(), "sender:   sleeping 2s before sending")
		time.Sleep(2 * time.Second)
		fmt.Println(at(), "sender:   about to send")
		ch <- "hello"
		fmt.Println(at(), "sender:   send completed")
	}()

	fmt.Println(at(), "receiver: about to receive (this will block)")
	msg := <-ch
	fmt.Println(at(), "receiver: got", msg)
}

The program runs this case and its mirror image back to back, so one command prints both. Here’s the first half:

$ go run ./03-blocking -case receiver source
1. receiver arrives first, so the receiver waits
------------------------------------------------
[ 0.0s] receiver: about to receive (this will block)
[ 0.0s] sender:   sleeping 2s before sending
[ 2.0s] sender:   about to send
[ 2.0s] sender:   send completed
[ 2.0s] receiver: got hello

The receiver sat at <-ch for two full seconds. Look at the last two lines: the send completing and the receive completing happen at the same moment. They are two halves of one event.

It works identically in reverse. If the sender is ready first then the sender waits, which is the second half of the same run, with the sleeps swapped:

$ go run ./03-blocking -case sender source
2. sender arrives first, so the sender waits
--------------------------------------------
[ 0.0s] sender:   about to send (this will block)
[ 0.0s] receiver: sleeping 2s before receiving
[ 2.0s] receiver: about to receive
[ 2.0s] receiver: got hello
[ 2.0s] sender:   send completed

Why blocking is the good part

New Go programmers usually read blocking as a limitation to work around. It is closer to the opposite. Blocking is the feature you are paying for.

It gives you synchronization for free. When <-results returns a value, you know with certainty that the sending goroutine got far enough to produce it. You didn’t write any locking, any flag, or any “is it ready yet” polling. The ordering is guaranteed by the language: everything the sender did before the send is visible to the receiver after the receive. (That guarantee has a formal name, the happens-before relationship, and it is the same one a mutex gives you.)

It also gives you backpressure, which matters as soon as you have real volume. A fast producer physically cannot outrun a slow consumer, because the send won’t complete until the consumer takes the value. Memory stays flat with no effort.


Buffered channels

Sometimes a strict rendezvous is more coupling than you want. make takes an optional capacity:

ch := make(chan int, 3) // room for 3 values

Now the rule changes:

A send blocks only when the buffer is full. A receive blocks only when the buffer is empty.

A buffered channel holds up to that many values on its own, so a sender can hand one over and carry on without a receiver being ready. That only lasts while there is room. Once the buffer is full the sender waits again, exactly as it did before.

Buffered: the sender only blocks once the buffer is full.
func main() {
	ch := make(chan int, 3)

	for i := 1; i <= 3; i++ {
		ch <- i // returns immediately while the buffer has room
		fmt.Printf("sent %d, returned immediately (len=%d cap=%d)\n", i, len(ch), cap(ch))
	}

	ch <- 4 // 🚩 the buffer is full and nobody is receiving, so this blocks forever
}
$ go run ./04-buffered -case overfill source
one send past capacity, with nobody receiving
---------------------------------------------
  buffer full (len=3 cap=3)
  sending a 4th value with nobody receiving...
fatal error: all goroutines are asleep - deadlock!

len(ch) is how many values are sitting in the buffer right now, and cap(ch) is the capacity. len on an unbuffered channel is always 0, because there is nowhere for a value to sit.

Start unbuffered. It is the stronger guarantee: when your send completes you know somebody actually took the value, whereas a buffered send only tells you the value was accepted for later.

Add a buffer when you have a specific reason. A small buffer absorbs bursts, so a producer that arrives in clumps doesn’t stall when the average rate is fine. A buffer of exactly N lets a sender finish a known-size handoff when the receiver only shows up later, which avoids a deadlock. Anything beyond that wants a measurement behind it, not a hunch that bigger sounds faster.

Remember that a buffer is memory you are committing. make(chan Request, 100_000) where each request is 2KB is a 200MB decision.


Closing a channel

Sending values is half the story. The other half is saying “that’s all.”

close(ch)

close doesn’t destroy the channel or free anything. It is a one-time announcement that no more values will ever be sent. Two things follow from it, and both are useful.

The first is that receiving from a closed channel returns immediately with the zero value, and keeps doing so forever after. The two-value form tells you whether the value was real:

v, ok := <-ch
// ok == true  → v is a genuine value
// ok == false → the channel is closed and drained; v is just the zero value

The second is that range over a channel loops until it is closed.

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

	go func() {
		for i := 1; i <= 3; i++ {
			ch <- i
		}
		close(ch) // without this, the range below hangs forever
	}()

	for v := range ch { // ends when ch is closed and empty
		fmt.Println("got", v)
	}
	fmt.Println("channel closed, loop finished")
}
$ go run ./05-close-range -case range source
1. range over a channel
-----------------------
  got 1
  got 2
  got 3
  channel closed, loop finished

Delete the close(ch) line and the program prints the three values, then hangs, then dies with all goroutines are asleep - deadlock!. The range loop is still waiting for a fourth value that will never come. Forgetting to close is an easy mistake to make, and the symptom is a hang after the work is already done.

Two conventions about closing

Neither of these is enforced by the compiler. Any goroutine holding a channel is allowed to close it, and nothing checks how many times. They are conventions because of what the runtime does when you break them.

Only the sender closes. Nothing stops a receiver from calling close, but if it does, a sender that is still going will panic the moment it sends again. The sender is the only party that knows there is nothing more coming, so it is the only one in a position to say so.

Close exactly once, because closing an already-closed channel panics as well.

Both conventions come free when there is one sender that closes right after its send loop, ideally with defer:

go func() {
	defer close(ch)
	for _, v := range values {
		ch <- v
	}
}()

With multiple senders it is genuinely harder, because nobody individually knows when the last value has been sent. The standard answer is a sync.WaitGroup and a small goroutine whose only job is to close after everyone is done:

go func() {
	wg.Wait()   // all senders have finished
	close(ch)
}()

You usually don’t need to close at all

Closing is not cleanup, which surprises people. An unclosed channel is garbage collected like anything else once nothing references it. You close a channel to communicate “no more values”, so if nobody needs that signal, you can skip it.

If you send exactly one result and the receiver takes exactly one value, don’t bother closing.


select: waiting on more than one thing

A plain receive waits on exactly one channel. select waits on several and proceeds with whichever is ready first:

// after(d, v) returns a channel that delivers v once d has elapsed
fast := after(100*time.Millisecond, "fast")
slow := after(500*time.Millisecond, "slow")

select {
case v := <-fast:
	fmt.Println("got:", v)
case v := <-slow:
	fmt.Println("got:", v)
}

That prints got: fast, and the second case never runs at all.

It is switch for channel operations. Some details worth knowing up front:

Two patterns cover most real uses. The first is a timeout. time.After returns a channel that delivers a value after a delay, so racing it against your real work gives you a deadline:

select {
case result := <-work:
	fmt.Println("finished:", result)
case <-time.After(2 * time.Second):
	fmt.Println("timed out")
}

The second is a non-blocking check. With default you can peek without committing:

select {
case v := <-ch:
	fmt.Println("got", v)
default:
	fmt.Println("nothing ready right now")
}

That’s useful, but if you find yourself putting it in a for loop to poll a channel, stop. That is a spin loop burning a CPU core. Just block on the receive, which is what blocking is for.


The complete behavior table

Every combination of operation and channel state, in one table. A lot of channel confusion turns out to be a cell in here that you had not run into yet.

Operationnil channelclosed channelopen, emptyopen, full
send ch <- vblocks foreverpanicunbuffered: blocks until a receiver is ready. buffered: completes immediatelyblocks until a receiver frees space
receive <-chblocks foreverreturns zero value immediately, ok == falseblocks until a valuereturns a value
close close(ch)panicpanicokok
len / cap0 / 0works normallyworks normallyworks normally

Three rows people meet the hard way.

Sending on a closed channel panics, and it is a real crash rather than an error you can check. That is why “only the sender closes” matters.

Receiving from a closed channel never blocks. It hands out zero values forever. If you range over a closed channel you get no iterations, and if you receive in a loop without checking ok, you get an infinite stream of zeros. Check ok when it matters.

Every operation on a nil channel blocks forever. That sounds like a pure footgun, and it does cause plenty of mystery hangs, since the zero value of a channel is nil:

var ch chan int // nil! make() was never called
ch <- 1         // blocks forever

It is also, surprisingly, useful. Since a nil channel is never ready, a nil case in a select is effectively disabled, which gives you a clean way to stop listening to one source while continuing with others:

for ch1 != nil || ch2 != nil {
	select {
	case v, ok := <-ch1:
		if !ok {
			ch1 = nil // disable this case; the loop keeps serving ch2
			continue
		}
		use(v)
	case v, ok := <-ch2:
		if !ok {
			ch2 = nil
			continue
		}
		use(v)
	}
}

Without that trick, a closed channel in a select is always ready, so the loop spins at full speed receiving zero values.


What channels are actually for

Those are the mechanics. The shapes below are the ones you will actually reach for.

1. Get a result back from a goroutine

This is the opening problem in its smallest form. A goroutine has no caller to return to, so a channel is the return path:

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

	go func() {
		result <- expensiveComputation()
	}()

	// ... do something else useful here ...

	fmt.Println("answer:", <-result) // blocks until it's ready
}

If there’s nothing useful to do in between, don’t start a goroutine. Just call the function.

2. Signal that something happened

When you don’t need to send data, only a notification, use chan struct{}. The empty struct occupies zero bytes, and you care about the event rather than the value:

done := make(chan struct{})

go func() {
	defer close(done) // announce completion, however we exit
	doWork()
}()

<-done // blocks until doWork finishes
fmt.Println("worker finished")

Closing is the signal here rather than sending, which leads directly to the next one.

3. Broadcast to many goroutines at once

A send goes to exactly one receiver. Closing a channel wakes up every goroutine waiting on it, so one event reaches every listener at once:

func main() {
	quit := make(chan struct{})

	for i := 1; i <= 3; i++ {
		go func() {
			fmt.Printf("worker %d: waiting\n", i)
			<-quit // all three are parked here
			fmt.Printf("worker %d: shutting down\n", i)
		}()
	}

	time.Sleep(100 * time.Millisecond)
	close(quit) // one close, three wakeups
	time.Sleep(100 * time.Millisecond)
}

You cannot do this with sends. Three sends would be needed, and you’d have to know there were three. close needs to know nothing about its audience.

This is exactly how context.Context works internally. ctx.Done() hands you a channel, and cancelling the context closes it, so every goroutine holding that context wakes up at once.

4. Distribute work across N workers

Multiple goroutines receiving from one channel is a work queue, and the runtime guarantees each value goes to exactly one of them. No locking required:

jobs := make(chan int)

for w := 1; w <= 3; w++ {
	go func() {
		for job := range jobs { // three workers, one channel
			process(job)
		}
	}()
}

for i := 1; i <= 100; i++ {
	jobs <- i
}
close(jobs) // all three range loops end

That is a worker pool, and it is the shape most production channel code ends up in.

5. Build a pipeline

A goroutine that receives from one channel and sends to another is a stage. Chain them and each stage runs concurrently:

func generate(nums ...int) <-chan int {
	out := make(chan int)
	go func() {
		defer close(out)
		for _, n := range nums {
			out <- n
		}
	}()
	return out
}

func square(in <-chan int) <-chan int {
	out := make(chan int)
	go func() {
		defer close(out)
		for n := range in {
			out <- n * n
		}
	}()
	return out
}

func main() {
	for v := range square(generate(1, 2, 3, 4)) {
		fmt.Println(v) // 1 4 9 16
	}
}

Each stage is an ordinary loop, and each closes its output when its input dries up, so close cascades down the chain automatically. Because the channels are unbuffered, the whole pipeline processes one item at a time in constant memory, no matter how many items flow through it.

Note the return types. <-chan int is a receive-only channel, and you can also declare send-only (chan<- int), with the same arrow direction rule as always. Using directional types in signatures documents the flow and lets the compiler enforce it, so a stage that accidentally sends to its input won’t build.

6. Time out an operation

This one appeared earlier, but it belongs on the list because it comes up so often:

select {
case result := <-slowOperation():
	use(result)
case <-time.After(5 * time.Second):
	return errors.New("timed out")
}

One caveat for beginners: this doesn’t stop the slow operation, it only stops you waiting for it. The goroutine keeps running. To actually cancel work you need context.

7. Limit concurrency (a semaphore)

A buffered channel is a permit dispenser: a send takes a permit, a receive gives it back. The values never matter, only how full the buffer is, so the element type is struct{}, which is zero bytes wide.

sem := make(chan struct{}, 3) // 3 permits
var wg sync.WaitGroup

for _, task := range tasks {
	wg.Add(1)
	sem <- struct{}{} // acquire, and block here once 3 are out

	go func() {
		defer wg.Done()
		defer func() { <-sem }() // release, however we exit

		handle(task)
	}()
}
wg.Wait()

Three things are easy to get wrong here.

The acquire sits outside the goroutine, and that is what bounds anything. With three permits out, the send blocks the loop. Move it inside the go func() and you have spawned 10,000 goroutines to queue for permits.

defer <-sem doesn’t compile, because defer needs a function call and a receive is an expression, so the receive goes inside a closure. Using defer also returns the permit if handle panics, and a leaked permit shrinks the limit for good.

The WaitGroup isn’t optional. The loop ends when the last task is launched, with up to three still running.

Ten tasks go through that loop, and never more than three run at a time:

$ go run ./08-usecases -case semaphore source
7. limit concurrency (semaphore)
--------------------------------
  ran 10 tasks, peak concurrency 3 (limit 3)

That is how you avoid opening 10,000 simultaneous connections when you have 10,000 URLs.

8. Rate limiting

time.Tick gives you a channel that delivers on a schedule. Receiving from it before each operation paces your loop:

limiter := time.Tick(200 * time.Millisecond) // 5 per second

for _, req := range requests {
	<-limiter // wait for the next tick
	go handle(req)
}

Common advice says time.Tick leaks and you should always use time.NewTicker with defer ticker.Stop(). That stopped being true in Go 1.23: the garbage collector now recovers unreferenced tickers whether or not they were stopped, and go doc time.Tick states there is no longer any reason to prefer NewTicker. Calling Stop to stop the ticking is a behaviour choice now, and no longer a leak.


When not to use a channel

Go’s slogan is “share memory by communicating,” and beginners often take it to mean channels are always the right answer. They aren’t.

To protect shared state, use a mutex. If several goroutines need to read and write the same map or struct, a sync.Mutex is simpler, faster, and clearer than routing every access through a channel:

type Counter struct {
	mu sync.Mutex
	n  map[string]int
}

func (c *Counter) Inc(key string) {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.n[key]++
}

The rule of thumb is channels for transferring ownership of a value, mutexes for protecting shared state. If the value’s owner changes, use a channel. If it is a thing many goroutines legitimately share, lock it.

To wait for N goroutines to finish, use sync.WaitGroup. You can do it with a channel, but a WaitGroup says what it means.

For a simple counter, use sync/atomic. atomic.Int64.Add(1) is a single atomic operation, where a channel round trip involves the scheduler and costs orders of magnitude more.

For sequential code, just call the function. Concurrency costs you goroutine creation, scheduling and channel operations, so if step B needs step A’s result and there’s nothing else to do in between, b(a()) is faster and much easier to debug.

For very high-frequency tiny operations, batch. A channel handoff costs somewhere in the tens to low hundreds of nanoseconds, depending on the machine and on whether the channel is buffered, so once the work you are passing is comparable to that, you spend more time coordinating than working. Send slices of 1,000 items rather than 1,000 individual sends.


The mistakes you will make

These are the ones that catch people out, roughly in the order they tend to.

1. Unbuffered send with no receiver, in the same goroutine.

ch := make(chan int)
ch <- 1  // deadlock: nobody can receive, we're the only goroutine

The classic first-day error. Either start a goroutine to receive, or use a buffered channel.

2. Forgetting to close, with a range on the other end. The loop hangs after the last value. Use defer close(ch) in the sender.

3. Closing from the receiver, or closing twice. Panic. Only the sender closes, exactly once.

4. Sending on a nil channel. Blocks forever, silently. It usually means you declared var ch chan int and never called make.

5. Ignoring ok on a closed channel. You get an infinite stream of zero values instead of a hang, which is arguably worse, because the program looks like it’s working.

6. Goroutine leaks. A goroutine blocked on a channel nobody will ever use again never exits and never gets collected:

func leak() {
	ch := make(chan int)
	go func() {
		ch <- expensive() // if nobody receives, this goroutine is stuck forever
	}()
	return // we left. the goroutine is still parked.
}

Give it a buffer of 1, or make sure someone always receives, or add a select with a cancellation case.

7. Assuming order across multiple senders. Values from a single sender arrive in order. Values from several senders interleave arbitrarily.


The mental model

Channels in seven sentences:

  1. A channel is a typed pipe between goroutines: ch <- v sends, <-ch receives.
  2. An unbuffered channel has no storage. It is a meeting point, and both sides wait for each other.
  3. That blocking is the synchronization, which is why you don’t need a lock.
  4. A buffer adds slack: sends only block when it’s full, receives only when it’s empty.
  5. close announces “no more values.” It ends range loops and wakes every waiting receiver at once.
  6. Only the sender closes, exactly once. Sending on a closed channel panics.
  7. select waits on several channels, and a default case makes it non-blocking.

The judgment call in one line: use a channel to hand a value from one goroutine to another, and use a mutex to protect something they share.


Where to go next