A Go channel passes values between goroutines and makes them wait for each other. Learn unbuffered and buffered channels, closing, deadlocks, nil channels, and how select waits on several at once.
A goroutine on its own can’t hand a result back. It has no return value you can catch. Go’s answer is the channel: a typed pipe that one goroutine sends into and another receives from.
This post covers creating channels, the difference between unbuffered and buffered ones, closing, the deadlock error you’ll meet sooner or later, and select for waiting on several channels at once. Every program below was run on Go 1.26, and its output is pasted from the run.
Creating a channel, sending and receiving
A Go channel is made with make, and its type is chan T, where T is the type of value it carries. The arrow operator <- does both jobs: ch <- v sends v, and <-ch receives a value.
package main
import "fmt"
func main() {
ch := make(chan string)
go func() {
ch <- "hello from a goroutine"
}()
msg := <-ch
fmt.Println(msg)
fmt.Printf("%T\n", ch)
}
It prints:
hello from a goroutine
chan string
The goroutine sends a string, and main receives it. There’s no sleep and no loop checking whether the goroutine has finished. <-ch simply waits until a value arrives. That waiting is the other half of what a channel does.
Send-only and receive-only channel types
A function parameter can promise to use a channel in one direction only. chan<- int is a channel you can only send to. <-chan int is one you can only receive from. The arrow shows which way the values flow relative to chan.
package main
import "fmt"
func produce(out chan<- int) {
for i := range 3 {
out <- i * i
}
close(out)
}
func consume(in <-chan int) int {
total := 0
for v := range in {
total += v
}
return total
}
func main() {
ch := make(chan int)
go produce(ch)
fmt.Println(consume(ch))
}
It prints:
5
main passes an ordinary chan int to both functions, and Go converts it to the narrower type at each call. produce sends 0, 1 and 4, and consume adds them up. close and for range get their own section below.
The narrower type is checked by the compiler. If consume tries to send, the build stops:
package main
func consume(in <-chan int) {
in <- 1
}
func main() {
ch := make(chan int)
go consume(ch)
<-ch
}
The build fails with:
./main.go:4:2: invalid operation: cannot send to receive-only channel <-chan int in (variable of type <-chan int)
The message names the type twice, which reads oddly, but the point is clear. Directional types cost nothing at run time, and the compiler enforces them, so use them whenever a function only sends or only receives.
Unbuffered and buffered channels
A channel made with make(chan int) is unbuffered: it has no room to store a value. A channel made with make(chan int, 2) is buffered, with room for two. That one difference decides when a send has to wait.
- On an unbuffered channel, a send waits until another goroutine receives, and a receive waits until another goroutine sends. The two meet, the value passes, and both carry on. This meeting is often called a rendezvous.
- On a buffered channel, a send waits only when the buffer is full, and a receive waits only when it’s empty.
Watch both before reading the program behind them:
An unbuffered channel on top and a buffered channel with capacity 2 below. The unbuffered send waits until a receiver takes the value. The buffered sends of 1 and 2 finish at once because there are free slots, the send of 3 waits because the shelf is full, and it goes through as soon as a receiver takes 1 and frees a slot.
Here are those steps in words, in case the animation doesn’t play for you:
- On the unbuffered channel, the sender offers 1. Nobody is receiving, so the sender is blocked.
- A receiver arrives and takes 1. The send and the receive finish together, and both goroutines carry on.
- On the buffered channel with capacity 2, the sender sends 1 and then 2. Both go into free slots, so neither send waits.
lenis now 2. - The sender tries to send 3. Both slots are full, so this send is blocked.
- A receiver takes 1, the value at the front. 2 moves up, and one slot is free.
- 3 goes into the free slot, the blocked send finishes, and the sender carries on.
This program does the same steps, with a goroutine as the sender and main as the receiver. Only main prints, and each print comes after a channel operation that must already have happened, so the output order can’t change between runs:
package main
import "fmt"
func main() {
// Unbuffered: the hatch has no room, so a send waits for a receiver.
hatch := make(chan int)
sent := make(chan bool)
go func() {
hatch <- 1 // waits here until main receives
sent <- true
}()
fmt.Println("hatch: received", <-hatch)
<-sent
fmt.Println("hatch: the sender has moved on")
// Buffered: the shelf has 2 slots, so 2 sends finish with nobody receiving.
shelf := make(chan int, 2)
ready := make(chan bool)
done := make(chan bool)
go func() {
shelf <- 1
shelf <- 2
ready <- true
shelf <- 3 // the shelf is full, so this waits
done <- true
}()
<-ready
fmt.Println("shelf: len", len(shelf), "cap", cap(shelf))
fmt.Println("shelf: received", <-shelf)
<-done
fmt.Println("shelf: the send of 3 has finished, len", len(shelf))
fmt.Println("shelf: received", <-shelf)
fmt.Println("shelf: received", <-shelf)
}
It prints:
hatch: received 1
hatch: the sender has moved on
shelf: len 2 cap 2
shelf: received 1
shelf: the send of 3 has finished, len 2
shelf: received 2
shelf: received 3
In the first half, the sender can’t reach sent <- true until main has taken the 1. In the second half, the goroutine reaches ready <- true while nobody is receiving from shelf, so the first two sends didn’t wait. The send of 3 finishes only after main takes a value. The values come out in the order they went in, because a channel is first in, first out.
cap is the buffer size you passed to make, and len is how many values are waiting right now. An unbuffered channel has both at 0. Don’t make decisions on len in real code, though. By the time you act on it, another goroutine may have changed it.
Explain it like I’m ten
Picture a restaurant kitchen with two cooks. One makes the food, and the other puts it on trays.
An unbuffered channel is a small hatch in the wall between them. It has no shelf. The first cook holds a plate up to the hatch and can’t let go of it until the second cook takes it from the other side. If the second cook is busy, the first cook just stands there holding the plate. When the plate changes hands, both cooks go back to work.
A buffered channel is a shelf with a fixed number of slots, say two. The first cook can put a plate down and walk away, as long as there’s an empty slot. When both slots are full, the cook has to stand and wait for a slot to free up. The second cook takes plates from the front of the shelf. If the shelf is empty, the second cook waits.
The precise version
A channel is a queue with a lock inside, shared by every goroutine that holds it. An unbuffered channel has a queue of size zero. A send on it completes only when a receiver takes the value directly, so the two goroutines are guaranteed to meet. After an unbuffered send returns, you know the receiver has the value.
A buffered channel of capacity N holds up to N values. A send blocks only when N values are already waiting, and a receive blocks only when none are. After a buffered send returns, you know only that the value is in the buffer. Nobody may have received it yet.
“Blocks” means the goroutine is parked. It isn’t spinning or using CPU. The runtime wakes it up when the channel can make progress.
Where the analogy breaks: a kitchen shelf holds plates of any kind, but a channel carries exactly one type. Several cooks can wait at the same hatch, and the language doesn’t promise which of them goes next. And the biggest difference comes up in the section on closing: a closed hatch doesn’t stay shut. It keeps handing out empty plates forever.
Deadlock: when every goroutine is waiting
A send on an unbuffered channel with no other goroutine to receive it waits forever. If every goroutine in the program is stuck like that, Go notices and stops the program:
package main
import "fmt"
func main() {
ch := make(chan int)
ch <- 1
fmt.Println(<-ch)
}
It prints the error and a stack trace, then stops:
fatal error: all goroutines are asleep - deadlock!
goroutine 1 [chan send]:
main tried to send, and the only goroutine that could ever receive is main itself, which is now stuck on the send. The receive on the next line never runs. The trace shows what the goroutine was doing when it stopped: [chan send].
Changing the first line to make(chan int, 1) makes this program work, because the value fits in the buffer. In real code, though, adding a buffer to make a deadlock go away usually hides a design problem.
This is a fatal error, not a panic, so recover can’t catch it. And the check only fires when every goroutine is blocked. If one goroutine is stuck on a channel forever while others are still running, such as an HTTP server’s goroutines, Go says nothing. That’s a goroutine leak, and you’ll only notice it as memory that keeps growing.
Closing a channel
A sender calls close(ch) to say “no more values are coming”. Receivers can still take any values already in the buffer. After that, every receive returns straight away with the zero value of the type. The two-value form tells you which you got:
package main
import "fmt"
func main() {
ch := make(chan int, 2)
ch <- 10
close(ch)
v, ok := <-ch
fmt.Println(v, ok)
v, ok = <-ch
fmt.Println(v, ok)
v, ok = <-ch
fmt.Println(v, ok)
}
It prints:
10 true
0 false
0 false
The 10 was sent before the close, so the first receive still gets it, with ok true. The channel is then closed and empty, so every later receive gives 0, false at once. It doesn’t block, and it doesn’t stop. That’s the “empty plates forever” the analogy promised. It’s the same comma-ok idea as looking up a key in a map.
Checking ok by hand gets tedious, so for v := range ch does it for you. The loop receives values until the channel is closed and empty, then ends:
package main
import "fmt"
func main() {
words := make(chan string)
go func() {
for _, w := range []string{"flour", "eggs", "milk"} {
words <- w
}
close(words)
}()
for w := range words {
fmt.Println("got", w)
}
fmt.Println("the channel is closed, so the loop ended")
}
It prints:
got flour
got eggs
got milk
the channel is closed, so the loop ended
If the goroutine forgot close(words), the loop would wait for a fourth word forever, and the program would end with the deadlock error from the previous section.
Only the sender closes
Sending on a closed channel is a panic, and so is closing a channel twice:
package main
import "fmt"
func main() {
ch := make(chan int, 1)
close(ch)
fmt.Println("closed")
ch <- 1
}
It prints the first line, then stops:
closed
panic: send on closed channel
Closing twice gives panic: close of closed channel. A receiver can’t know whether a sender is about to send, so a receiver that closes a channel risks this panic. The rule that keeps you safe is simple: only the sender closes, and only when it has nothing more to send. With several senders, none of them should close. Something that knows they’ve all finished closes it instead. The part on sync shows the usual tool for that.
You also don’t have to close every channel. A channel isn’t a file. The garbage collector cleans up an unreachable channel whether it was closed or not. Close a channel when receivers need to know that the values have stopped, as range does.
nil channels block forever
The zero value of a channel type is nil, and var ch chan int gives you one. Sending to or receiving from a nil channel blocks forever:
package main
import "fmt"
func main() {
var ch chan int
fmt.Println(ch == nil)
<-ch
}
It prints true, then the deadlock error:
true
fatal error: all goroutines are asleep - deadlock!
goroutine 1 [chan receive (nil chan)]:
The trace even says (nil chan), which helps when you forgot a make. Closing a nil channel panics too.
That sounds like a pure trap, but it has one good use. In a select, a case on a nil channel is never ready, so setting a channel variable to nil switches that case off. You’ll see that in the section on merging two channels.
select: waiting on several channels
A select statement waits until one of several channel operations can go ahead, then runs that case. It looks like a switch, but every case is a send or a receive:
package main
import "fmt"
func split(nums []int, evens, odds chan<- int, done chan<- bool) {
for _, n := range nums {
if n%2 == 0 {
evens <- n
} else {
odds <- n
}
}
done <- true
}
func main() {
evens := make(chan int)
odds := make(chan int)
done := make(chan bool)
go split([]int{4, 7, 1, 8}, evens, odds, done)
for {
select {
case n := <-evens:
fmt.Println("even:", n)
case n := <-odds:
fmt.Println("odd:", n)
case <-done:
fmt.Println("done")
return
}
}
}
It prints:
even: 4
odd: 7
odd: 1
even: 8
done
split sends each number on one of two unbuffered channels, then signals done. Each send waits until main receives it, so only one case is ready at a time, and the output keeps the order of the input.
When several cases are ready
If more than one case is ready when select looks, Go picks one of them at random, with equal chance. It doesn’t try them top to bottom like a switch. This is on purpose. If select always preferred the first case, a busy first channel could starve the others forever.
The program above prints in a fixed order only because it never has two cases ready at once. If split used buffered channels, several numbers could be waiting at the same time, and the order of the output would change between runs. So don’t rely on case order for priority. If one channel really must win, check it first in its own select, or design the flow so it can’t compete.
default: don’t wait at all
A select with a default case never blocks. If no other case is ready right now, default runs instead. That gives you a send or receive that only happens if it can happen immediately:
package main
import "fmt"
func trySend(ch chan<- int, v int) {
select {
case ch <- v:
fmt.Println("sent", v)
default:
fmt.Println("full, skipped", v)
}
}
func tryReceive(ch <-chan int) {
select {
case v := <-ch:
fmt.Println("received", v)
default:
fmt.Println("empty, nothing to receive")
}
}
func main() {
shelf := make(chan int, 2)
trySend(shelf, 1)
trySend(shelf, 2)
trySend(shelf, 3)
tryReceive(shelf)
tryReceive(shelf)
tryReceive(shelf)
}
It prints:
sent 1
sent 2
full, skipped 3
received 1
received 2
empty, nothing to receive
This is the shelf from the animation, but the send of 3 doesn’t wait. It gives up and takes the default. You’d use this to drop a metric rather than slow a request down.
Be careful with default inside a loop. A loop around a select with default never blocks, so it spins and burns a whole CPU core while it waits for something to happen.
A timeout with time.After
time.After(d) returns a channel that receives a value once d has passed. Put it in a select next to the real work, and whichever is ready first wins:
package main
import (
"fmt"
"time"
)
func fetch(delay time.Duration) <-chan string {
out := make(chan string, 1)
go func() {
time.Sleep(delay)
out <- "report ready"
}()
return out
}
func wait(result <-chan string, limit time.Duration) {
select {
case r := <-result:
fmt.Println(r)
case <-time.After(limit):
fmt.Println("gave up waiting")
}
}
func main() {
wait(fetch(0), time.Second)
wait(fetch(time.Second), 10*time.Millisecond)
}
It prints:
report ready
gave up waiting
The first job finishes at once against a one-second limit. The second takes a second against a 10-millisecond limit. The gaps are wide on purpose, so the result is the same on a slow machine.
Two details are worth knowing. fetch uses a buffer of 1, so the goroutine can still send its late result after wait has given up, and then exit. With an unbuffered channel, it would block forever on a send nobody will receive. And since Go 1.23, a timer from time.After that nothing refers to any more is cleaned up by the garbage collector even if it never fired. Older advice warned that time.After in a loop leaks timers. That’s no longer true.
For timeouts that reach through several function calls, such as an HTTP request that calls a database, use context, covered in the part on context and concurrency patterns.
Merging two channels, with nil to switch a case off
Receiving from two channels until both are closed is where a nil channel earns its place. When one channel closes, set its variable to nil, and select stops picking that case:
package main
import (
"fmt"
"slices"
)
func send(nums ...int) <-chan int {
out := make(chan int)
go func() {
for _, n := range nums {
out <- n
}
close(out)
}()
return out
}
func main() {
a := send(1, 2, 3)
b := send(10, 20)
var got []int
for a != nil || b != nil {
select {
case n, ok := <-a:
if !ok {
a = nil // a nil channel is never ready, so this case is now off
continue
}
got = append(got, n)
case n, ok := <-b:
if !ok {
b = nil
continue
}
got = append(got, n)
}
}
slices.Sort(got)
fmt.Println(got)
}
It prints:
[1 2 3 10 20]
Without a = nil, a closed a would be ready on every loop, handing out 0, false over and over, and the loop would spin. With it, the case can never be chosen again. The loop ends when both are nil. The values from a and b arrive in an order that changes between runs, so the program sorts them before printing.
Two patterns to start with
A lot of Go concurrency is built from small functions that return channels. Two are worth learning now. Worker pools and pipelines build on them in the part on context and concurrency patterns.
A generator returns a receive-only channel
A generator is a function that starts a goroutine, returns a <-chan T, and sends values on it:
package main
import "fmt"
func countdown(from int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for i := from; i > 0; i-- {
out <- i
}
}()
return out
}
func main() {
for n := range countdown(3) {
fmt.Println(n)
}
fmt.Println("liftoff")
}
It prints:
3
2
1
liftoff
The return type <-chan int means callers can only receive, so only the goroutine inside can send or close. defer close(out) makes sure the channel closes however the goroutine ends, and that lets the caller use range.
A done channel stops a goroutine
A generator that never ends needs a way to be told to stop, or its goroutine waits forever on a send nobody receives. The usual signal is a done channel that the caller closes:
package main
import "fmt"
func naturals(done <-chan struct{}) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for i := 1; ; i++ {
select {
case out <- i:
case <-done:
return
}
}
}()
return out
}
func main() {
done := make(chan struct{})
nums := naturals(done)
for n := range nums {
fmt.Println(n)
if n == 3 {
break
}
}
close(done)
for range nums {
// drain until the generator closes out
}
fmt.Println("the generator has stopped")
}
It prints:
1
2
3
the generator has stopped
The goroutine waits on two things at once: someone receiving its next number, or done being closed. After main breaks out of the loop, it closes done. A closed channel is always ready to receive from, so the goroutine returns and closes out. The second loop ends only once out is closed, which proves the goroutine really stopped.
Closing is the right signal here, not sending a value. A send wakes one receiver. A close wakes every receiver, however many goroutines are listening. The element type struct{} takes no memory and says that the value doesn’t matter, only the event.
Share memory by communicating
Go’s advice is “don’t communicate by sharing memory; share memory by communicating.” In plain terms: instead of several goroutines reading and writing the same variable and taking turns with a lock, pass the data along a channel so that one goroutine owns it at a time. The sender hands a value over and stops touching it, and the receiver becomes its owner. Nobody else has it, so there’s nothing to fight over. That isn’t a ban on locks. A mutex is simpler for a counter or a cache, and the part on sync covers those. Reach for channels when data moves from one stage of work to the next, or when goroutines need to signal each other.
What to remember
make(chan T)is unbuffered: a send waits until a receiver takes the value.make(chan T, n)holds up tonvalues, and sends wait only when it’s full.- Use
chan<- Tand<-chan Tin function signatures. The compiler then stops a receiver from sending. - If every goroutine is blocked, Go stops with
all goroutines are asleep - deadlock!. If only some are, nothing warns you. - Only the sender closes. A closed channel gives the zero value and
ok == falseforever,rangestops at close, and sending on a closed channel panics. - A nil channel blocks forever. In a
select, setting a channel tonilswitches its case off. selectwaits on several channels and picks at random among ready cases.defaultmakes it non-blocking, andtime.Aftergives it a timeout.- Closing a
donechannel tells every listening goroutine to stop.
A send on an unbuffered channel doesn’t finish until someone has the value.