Skip to content

Channels

A channel is a typed conduit through which goroutines communicate. Sending a value into a channel and receiving from it are the two operations that transfer both data and synchronization in a single step.

ch := make(chan int) // unbuffered channel of int
ch <- 42 // send (blocks until a receiver is ready)
v := <-ch // receive (blocks until a sender is ready)

The type system enforces what flows through a channel. A chan string will not accept an int.

UnbufferedBuffered
Created withmake(chan T)make(chan T, capacity)
Send blocks?Yes — until a receiver is readyNo — until the buffer is full
Receive blocks?Yes — until a sender is readyNo — until the buffer is empty
Synchronizes?Yes — sender and receiver meet at the same momentNo — the buffer decouples timing

Unbuffered channels provide the strongest synchronization guarantee: the sender knows the receiver has the value before it continues.

You can restrict a channel parameter to send-only or receive-only to encode the intent at the type level:

func producer(out chan<- int) { out <- 1 } // send-only
func consumer(in <-chan int) { v := <-in; _ = v } // receive-only

A bidirectional chan T is assignable to either direction. The compiler enforces that a receive-only channel cannot be sent to, and vice versa.

The sender calls close(ch) to signal that no more values will be sent. Receivers can use a for range loop to drain all values until close:

close(ch)
for v := range ch { // exits when ch is closed and empty
fmt.Println(v)
}

A receive expression can return a second boolean that tells you whether the channel is still open:

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

A channel variable that is only declared, never make’d, is nil:

var ch chan int // nil — not the same as make(chan int)

Every operation on a nil channel blocks forever — not panics, blocks. Both a send and a receive on a nil channel park the goroutine with nothing that could ever wake it:

var ch chan int
<-ch // blocks forever

Run as the only goroutine in main, this is a real deadlock: a state where a set of goroutines are all blocked, each waiting on something that only another blocked goroutine in the same set could provide, so none of them can ever make progress. The Go runtime detects the specific case where every goroutine in the program is blocked and terminates with a fatal error, not a panic — it cannot be recovered with recover():

fatal error: all goroutines are asleep - deadlock!
goroutine 1 [chan receive (nil chan)]:
main.main()
/tmp/nilchandeadlock/main.go:5 +0x2c
exit status 2

The [chan receive (nil chan)] tag in the goroutine trace is the tell — it names the nil channel as the cause, not just “blocked”. A nil channel send deadlocks identically, reported as [chan send (nil chan)]. This is why a zero-value chan field that a struct forgot to initialize is a classic AI-generated bug: it compiles, and it hangs the first time anything touches it.

Nil channels are not purely a bug, though — a nil case in a select is disabled rather than blocking, which is a deliberate technique covered in the Select lesson.

The three operations — send, receive, close — behave differently depending on whether the channel is nil, open, or closed:

Operationnil channelopen channelclosed channel
ch <- v (send)blocks foreverblocks until a receiver is ready (unbuffered) or the buffer has roompanics: send on closed channel
<-ch (receive)blocks foreverblocks until a value is availablereturns immediately: zero value, ok == false
close(ch)panics: close of nil channelcloses it — any blocked receivers unblock immediatelypanics: close of closed channel
len(ch)0number of buffered elements not yet receivednumber of buffered elements still unreceived
cap(ch)0the buffer capacitythe buffer capacity (unchanged by closing)

All three panic messages and the deadlock message above are copied verbatim from real go run output on Go 1.26.5. The pattern worth memorizing: nil blocks, closed panics on send and never blocks on receive.

package main
import "fmt"
func producer(ch chan<- int, n int) {
for i := 1; i <= n; i++ {
ch <- i
}
close(ch)
}
func main() {
// Unbuffered channel: producer runs in a goroutine
ch := make(chan int)
go producer(ch, 5)
for v := range ch {
fmt.Println(v)
}
// Buffered channel: all sends fit in the buffer
buf := make(chan string, 3)
buf <- "a"
buf <- "b"
buf <- "c"
close(buf)
for s := range buf {
fmt.Println(s)
}
// Comma-ok: distinguish real value from closed zero value
done := make(chan bool, 1)
done <- true
close(done)
v1, ok1 := <-done
fmt.Println(v1, ok1) // true true
v2, ok2 := <-done
fmt.Println(v2, ok2) // false false
}

Closing a channel wakes up every goroutine currently blocked receiving on it, not just one — this is different from a normal receive, which only ever delivers a value to a single waiting goroutine. Combined with chan struct{}, this makes close the idiomatic way to broadcast “stop” or “ready” to an unknown number of goroutines at once.

struct{} is the empty struct — it has no fields and occupies zero bytes. A chan struct{} is used purely as a signal; nobody cares about the value received, only about the fact that the receive unblocked:

done := make(chan struct{})
go func() {
<-done // unblocks the instant done is closed
fmt.Println("goroutine 1 saw the signal")
}()
go func() {
<-done // unblocks at the same instant, independently
fmt.Println("goroutine 2 saw the signal")
}()
close(done) // broadcasts to both goroutines simultaneously

Sending a single value on done would only wake one of the two goroutines — the other would block forever. close is the only channel operation that reaches every waiter at once.

Channels are not just a scheduling convenience — they are part of the Go memory model’s guarantee about when writes made in one goroutine become visible in another. The rule: a send on a channel happens-before the corresponding receive completes, and for a channel with capacity C, the k-th send happens-before the (k+C)-th receive completes. In practice this means any variable a goroutine wrote before it sent on a channel is guaranteed to be visible to whatever goroutine reads it after receiving from that channel — no separate mutex or atomic needed for that specific handoff.

This guarantee is also why Go channels have a clear ownership rule: whichever goroutine creates a channel is the one that sends on it and closes it; every other goroutine only ever holds a receive-only <-chan T and never closes or sends. A function that returns a channel should return the directional type to make this enforceable by the compiler, not just by convention:

// producer owns out: it is the only goroutine that sends to it or closes it.
func producer(n int) <-chan int {
out := make(chan int, n)
go func() {
defer close(out) // the owner closes, always
for i := 1; i <= n; i++ {
out <- i
}
}()
return out // callers only ever see <-chan int
}

Violating this — for example, two goroutines that both think they own the same channel and both call close — is exactly the bug in this lesson’s “Reading AI-generated code” section below.

A buffered channel’s capacity can be used purely for counting, not for carrying data — this is the classic Go implementation of a counting semaphore. Acquire a permit by sending into the channel (blocks once the buffer is full); release it by receiving:

sem := make(chan struct{}, 2) // at most 2 permits
sem <- struct{}{} // acquire — blocks if 2 permits are already out
// ... critical section, at most 2 goroutines here at once ...
<-sem // release

This is the simplest form of the bounded-parallelism pattern covered in full in the Patterns lesson — worth recognising here because it is a second, very different use of a buffered channel’s capacity beyond just “smoothing out timing.”

The snippet below runs all three ideas: close as a broadcast to three waiting goroutines, a chan struct{} buffered to 2 used as a semaphore, and a producer that owns its channel end to end.

package main
import (
"fmt"
"sort"
"sync"
)
func main() {
// close as broadcast: every receiver unblocks the instant the channel
// closes, no matter how many goroutines are waiting.
done := make(chan struct{})
var wg sync.WaitGroup
results := make([]string, 3)
for i := 0; i < 3; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
<-done // unblocks for every goroutine at once when done is closed
results[id] = fmt.Sprintf("worker %d saw the broadcast", id)
}(i)
}
close(done) // broadcasts to all 3 waiters simultaneously
wg.Wait()
for _, r := range results {
fmt.Println(r)
}
// buffered channel as a counting semaphore: only 2 permits, so at most 2
// goroutines run their critical section concurrently.
sem := make(chan struct{}, 2)
var mu sync.Mutex
inFlight, maxInFlight := 0, 0
var wg2 sync.WaitGroup
for i := 0; i < 5; i++ {
wg2.Add(1)
go func() {
defer wg2.Done()
sem <- struct{}{} // acquire
mu.Lock()
inFlight++
if inFlight > maxInFlight {
maxInFlight = inFlight
}
mu.Unlock()
mu.Lock()
inFlight--
mu.Unlock()
<-sem // release
}()
}
wg2.Wait()
fmt.Println("never exceeded 2 concurrent holders:", maxInFlight <= 2)
// ownership: the sender both sends and closes; the consumer only ever
// holds a <-chan so it physically cannot close or send.
ch := ownedProducer(3)
var got []int
for v := range ch {
got = append(got, v)
}
sort.Ints(got)
fmt.Println(got)
}
// ownedProducer owns out: it is the only goroutine that sends to it or
// closes it. Callers only ever see the receive-only <-chan int.
func ownedProducer(n int) <-chan int {
out := make(chan int, n)
go func() {
defer close(out)
for i := 1; i <= n; i++ {
out <- i
}
}()
return out
}
flowchart TD
  subgraph Unbuf["Unbuffered channel -- rendez-vous point"]
    G1u["G1"] -->|"send (blocks)"| CHu["channel"]
    G2u["G2"] -->|"recv (blocks)"| CHu
    CHu --> Meet["G1 and G2 must meet"]
  end
  subgraph Buf["Buffered channel (cap=3)"]
    G1b["G1"] -->|"send"| Slots["[_][_][_]"]
    Slots -->|"recv"| G2b["G2"]
    Slots -.->|"blocks when full"| G1b
    Slots -.->|"blocks when empty"| G2b
    Slots --> Indep["G1/G2 run more independently"]
  end
An unbuffered channel forces the sender and receiver to meet; a buffered channel only blocks once the buffer is full or empty
flowchart LR
  subgraph Lifecycle["chan T lifecycle"]
    Nil["nil
(var ch chan T)"]
    Open["open
(make(chan T, n))"]
    Closed["closed
(after close(ch))"]
  end
  Nil -->|make chan T| Open
  Open -->|close ch| Closed
  Nil -.send / receive: blocks forever.-> Nil
  Closed -.send: panic!.-> Closed
  Closed -.receive: zero value, ok=false, never blocks.-> Closed
A channel moves nil → open → closed and never back; each state has its own send/receive rules
What you getBenefitCost
unbuffered channelstrongest synchronization guaranteesending G blocks until a receiving G is ready
buffered channeldecouples sender/receiver timingmust size the buffer correctly, can hide backpressure
channel direction (chan<-, <-chan)compile-time safetymore verbose function signatures
close + rangeeasy completion notificationsending after close panics
  • Channels are always faster than mutexes — a channel has more overhead than a mutex for a simple counter
  • Buffered channels prevent deadlock — not always; deadlock can still happen if the buffer is full and there is no receiver
  • close(ch) discards data in the buffer — no; a receiver can still drain the remaining buffered values after close
  • The receiver can close the channel — only the sender should close a channel

When a generated snippet creates or closes a channel, check for these before trusting it:

  • Is there exactly one goroutine that ever calls close on this channel, or could two goroutines both reach a close call on the same one?
  • Does every goroutine that sends on this channel have a receiver that is guaranteed to still be reading — or could the receiver have already returned, leaving the sender blocked forever?
  • Is a channel variable used before it is make’d (a nil channel field on a zero-value struct, or a var ch chan T that was never assigned)? Every operation on it blocks forever instead of panicking.
  • Does a function that only ever sends declare its channel parameter as bidirectional (chan T) instead of chan<- T? Not a bug, but a missed compile-time guardrail against an accidental receive.
  • On every path — including error returns — is a send-after-close actually impossible, or does an early-return branch skip past a point where the channel is already closed?

🐛 Spot the bug

func merge(chs ...<-chan int) <-chan int {
out := make(chan int)
var wg sync.WaitGroup
for _, c := range chs {
wg.Add(1)
go func(c <-chan int) {
defer wg.Done()
for v := range c {
out <- v
}
close(out) // called once per input channel
}(c)
}
return out
}
Show the answer

Every one of the len(chs) goroutines calls close(out) when its own input channel drains — but out is shared. The first goroutine to finish closes it; every other goroutine that finishes afterward panics on its own close(out) call, and any goroutine still trying to send on it panics with send on closed channel. The real output on Go 1.26.5 (order varies by run):

panic: send on closed channel
goroutine 19 [running]:
main.merge.func1(0xc0001c8000)
/tmp/mergebug/main.go:16 +0x98

go run -race additionally reports a WARNING: DATA RACE between the racing send and the receive that triggers close — this is exactly the “who closes” ownership question from earlier in this lesson. The fix: nobody closes out inside the per-channel goroutines at all. A single dedicated goroutine calls wg.Wait() and then close(out) exactly once, after every input goroutine has finished — see the fan-in merge in the Patterns lesson for the corrected version.

What happens when you receive from an unbuffered channel when no goroutine is sending?
You call close(ch) on a buffered channel that still has 3 items in it. What happens when you range over it?
What does the second return value ok in v, ok := <-ch tell you?
Which channel direction annotation restricts a parameter to receive-only?
What happens when this runs?
func main() {
    var ch chan int
    ch <- 1
}
What does this print?
ch := make(chan int, 1)
close(ch)
v, ok := <-ch
fmt.Println(v, ok)