Channels and Select

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Go 1.27 (go1.27.2), checked on 2026-10-18; every go.mod shown has the line go 1.27. A channel is a typed conduit through which one goroutine hands values to another, and select lets a goroutine wait on several channel operations at once. Together they are Go’s built-in answer to the question "how do goroutines communicate?", an answer taken from Hoare’s communicating sequential processes (CSP): do not communicate by sharing memory, share memory by communicating. This page is the complete reference for both: the types and operations, the behaviour table for every combination of nil, open and closed channels, select in all its forms, timers, deadlocks, leaks and the race detector. The goroutines themselves are on Goroutines and the Scheduler. Pipelines, worker pools and cancellation built from these pieces are on Concurrency Patterns and Context; locks and atomics are on Sync and Atomics; the ordering guarantees channels provide are formalised in Memory Model and Data Races. For the same ideas in a neighbouring language see the Rust Reference page Threads & Message Passing, whose mpsc channels are the closest equivalent.

Every example was compiled, vetted with go vet and run with Go 1.27.2; the output shown is the real output (the compiler messages and panics are likewise copied from real runs). Examples whose scheduling order varies from run to run were rewritten to print only order-independent results, so the output shown is reproducible. The go.mod of the example module is:

module example.com/shelf

go 1.27

What a channel is

A channel is a reference to a runtime object that contains a queue of values of one element type, a lock and two lists of goroutines that are parked waiting to send or to receive. Like a map or a slice header, the channel value is a small handle: copying it, passing it to a function or storing it in a struct yields another handle to the same queue. The zero value of a channel type is nil, and a nil channel is a perfectly legal, if peculiar, value (see the axioms).

Three properties make channels more than a thread-safe queue:

  • synchronisation: an unbuffered channel makes sender and receiver meet, so a successful communication is also a synchronisation point. The Go memory model (The Go Memory Model) states the resulting happens-before edges, so data written before a send is visible after the matching receive without any lock;

  • ownership transfer: sending a value, or a pointer to it, is the natural way to say "this is yours now". If the sender stops touching it afterwards, there is no sharing and so no race;

  • selection: a goroutine can wait for the first of several channel operations, with a timeout or a cancellation signal among them. No other primitive in the standard library composes like this.

Channels are not always the right tool. Protecting a counter or a cache is simpler and faster with a sync.Mutex; the community wiki page Mutex or Channel gives the rule of thumb that channels fit passing ownership, distributing units of work and communicating asynchronous results, while mutexes fit caches and state. See Sync and Atomics.

Channel types and make

The specification (Channel types) defines three forms:

Type Meaning Operations allowed

chan T

bidirectional channel of T

send, receive, close, len, cap, range

chan<- T

send-only channel of T

send, close, len, cap

<-chan T

receive-only channel of T

receive, range, len, cap

The <- operator associates with the leftmost chan it can, so chan<- chan int is a send-only channel of bidirectional channels, chan<- <-chan int a send-only channel of receive-only channels and <-chan <-chan int a receive-only channel of receive-only channels. Use parentheses, as in chan (<-chan int), when you want the other reading. The element type may be anything, including another channel, a function, an interface or struct{} (a zero-size element, used when only the event matters).

Channels are created with the built-in make (Making slices, maps and channels):

  • make(chan T) or make(chan T, 0) creates an unbuffered (synchronous) channel;

  • make(chan T, n) creates a buffered channel with room for n values;

  • the capacity must be a non-negative integer. A negative constant is a compile-time error and a negative run-time value panics (both are shown below).

Channel values are comparable: two channels are equal if they came from the same make call or both are nil. They can therefore be map keys and can be compared with nil. A directional type is assignable from the bidirectional type automatically (a function taking <-chan T accepts a chan T argument) but never the other way round, so the direction is a one-way narrowing of a capability.

package main

import "fmt"

type LoanState int

const (
	Available LoanState = iota
	OnLoan
	Returned
)

func (s LoanState) String() string { return [...]string{"Available", "OnLoan", "Returned"}[s] }

type event struct {
	isbn  string
	state LoanState
}

func main() {
	events := make(chan event, 4)
	var nilEvents chan event
	fmt.Printf("%T cap=%d nil=%t\n", events, cap(events), nilEvents == nil)
	fmt.Println("comparable:", events == events, events == nilEvents)

	events <- event{"978-0-441-17271-9", OnLoan}
	events <- event{"978-0-441-17271-9", Returned}
	close(events)
	for e := range events {
		fmt.Println(e.isbn, e.state)
	}

	chOfCh := make(chan chan int, 1) // channel of channels
	inner := make(chan int, 1)
	chOfCh <- inner
	(<-chOfCh) <- 7
	fmt.Println(<-inner)
	var _ <-chan <-chan int // <-chan (<-chan int) parses right to left
}
chan main.event cap=4 nil=true
comparable: true false
978-0-441-17271-9 OnLoan
978-0-441-17271-9 Returned
7

Unbuffered channels: the hand-off

On an unbuffered channel a send completes only when a receiver has taken the value, and a receive completes only when a sender has supplied one. Whichever goroutine arrives first parks and waits for the other. This is why the specification calls it a synchronous channel and why the memory model lets you treat the receive as "happens before" the completion of the send.

package main

import (
	"fmt"
	"sync"
)

func main() {
	ch := make(chan string)
	var wg sync.WaitGroup
	wg.Add(1)
	go func() {
		defer wg.Done()
		fmt.Println("receiver got:", <-ch)
	}()
	ch <- "Dune" // returns only once the receiver has taken the value
	wg.Wait()
}
receiver got: Dune

The diagram shows the order of events when a sender arrives first, which is the common case in a pipeline.

sequenceDiagram participant S as Sender goroutine participant C as Channel (cap 0) participant R as Receiver goroutine S->>C: send Dune Note over S: no receiver yet, sender parks in the send queue R->>C: receive C-->>R: Dune (copied directly from the sender) C-->>S: wake up, send has completed Note over S,R: both continue, the receive happens before the send completes

A full worked example that iterates over a stream is in the next sections. The example below uses a goroutine that sends two books and then closes the channel, while main ranges over it:

package main

import "fmt"

type Book struct {
	Title string
	Pages int
}

func main() {
	books := make(chan Book) // unbuffered: capacity 0

	go func() {
		defer close(books)
		books <- Book{"Dune", 612}
		books <- Book{"Emma", 474}
	}()

	for b := range books {
		fmt.Printf("received %q (%d pages)\n", b.Title, b.Pages)
	}
	fmt.Println("len:", len(books), "cap:", cap(books))
}
received "Dune" (612 pages)
received "Emma" (474 pages)
len: 0 cap: 0

The rules in the specification (Send statements and Receive operator) can be summarised as follows:

  • in ch <- v the channel expression and the value are both evaluated before communication begins; the value must be assignable to the element type;

  • the send blocks until it can proceed. On an unbuffered channel that means a receiver is ready; on a buffered one it means there is a free slot;

  • <-ch is an expression of the element type. It blocks until a value is available, or until the channel is closed and drained, in which case it yields the zero value;

  • the value is copied. Sending a large struct copies it; sending a pointer copies the pointer and so shares the pointee, which is how data races sneak back in when the sender keeps using the pointer afterwards.

Buffered channels

A buffered channel decouples sender and receiver by the size of its buffer. A send blocks only when the buffer is full and a receive blocks only when it is empty. Values leave in the order they entered (FIFO).

A buffered channel with capacity three and two queued values, showing the blocking rules for a full and an empty buffer

The built-ins len(ch) and cap(ch) report the number of queued values and the capacity (Length and capacity). For an unbuffered or nil channel both are zero. Use them for metrics and diagnostics only: in a concurrent program len is stale the instant it returns, so if len(ch) < cap(ch) { ch <- v } is a race, not a non-blocking send. Use select with default for that.

package main

import "fmt"

func main() {
	queue := make(chan string, 3)
	fmt.Println("len:", len(queue), "cap:", cap(queue))

	queue <- "Dune"
	queue <- "Emma"
	fmt.Println("len:", len(queue), "cap:", cap(queue))

	queue <- "Ulysses"
	select {
	case queue <- "Hamlet":
		fmt.Println("sent Hamlet")
	default:
		fmt.Println("queue full, a plain send would block")
	}

	fmt.Println(<-queue, <-queue, <-queue)
	fmt.Println("len:", len(queue), "cap:", cap(queue))
}
len: 0 cap: 3
len: 2 cap: 3
queue full, a plain send would block
Dune Emma Ulysses
len: 0 cap: 3

A few guidelines for choosing a capacity:

  • capacity 0 gives the strongest synchronisation and exposes missing receivers as deadlocks early; start there;

  • capacity 1 is the usual choice for a result that is delivered once, as in the lookup helper in the timeout example: the producer can always complete its single send and exit, even if nobody listens any more;

  • a larger capacity absorbs bursts or limits concurrency (a buffered chan struct{} is a counting semaphore, see Concurrency Patterns), but it also hides backpressure: a big buffer only delays the moment the producer notices the consumer is too slow;

  • never pick a capacity "to make the deadlock go away". If the program needs the buffer to be large enough for every possible send, the buffer is acting as an unbounded queue and the design should say so.

The memory model gives a precise rule for buffered channels: the k-th receive from a channel with capacity C happens before the (k+C)-th send on that channel completes. For C = 0 this is the hand-off rule above.

Directional channels

A function that only produces values should accept a chan<- T; one that only consumes should accept a <-chan T. The compiler then enforces the intent, and the signature documents it.

package main

import "fmt"

func produce(out chan<- string, titles ...string) {
	for _, t := range titles {
		out <- t
	}
	close(out) // allowed on a send-only channel
}

func consume(in <-chan string) int {
	n := 0
	for t := range in {
		fmt.Println("shelved:", t)
		n++
	}
	return n
}

func main() {
	ch := make(chan string, 4) // bidirectional
	produce(ch, "Dune", "Emma", "Ulysses")
	fmt.Println("count:", consume(ch))
}
shelved: Dune
shelved: Emma
shelved: Ulysses
count: 3

The conversion happens implicitly where a bidirectional channel is assigned to a directional type, as in the two calls above. It cannot be reversed. These are the compile errors for each violation (the file is dirbad/main.go; each line is a separate mistake):

package main

func recvOnly(in <-chan int) {
	in <- 1
	close(in)
}

func sendOnly(out chan<- int) {
	<-out
	for range out {
	}
}

func main() {
	var in <-chan int
	var bi chan int = in
	_, _ = bi, in
	recvOnly(nil)
	sendOnly(nil)
}
# example.com/shelf/dirbad
dirbad/main.go:4:2: invalid operation: cannot send to receive-only channel <-chan int in (variable of type <-chan int)
dirbad/main.go:5:8: invalid operation: cannot close receive-only channel in (variable of type <-chan int)
dirbad/main.go:9:4: invalid operation: cannot receive from send-only channel chan<- int out (variable of type chan<- int)
dirbad/main.go:10:12: cannot range over out (variable of type chan<- int): receive from send-only channel chan<- int
dirbad/main.go:16:20: cannot use in (variable of type <-chan int) as chan int value in variable declaration

Notice that close is accepted on a send-only channel (produce does it above) and rejected on a receive-only one: closing is a statement by the sender that no more values will come, so it belongs to whoever may send. Returning <-chan T from a constructor, as gen does in later examples, is the idiom for a stream the caller can only read.

Close, comma-ok and range

close(ch) records that no more values will be sent. It does not discard buffered values and it does not stop receivers from reading them. The built-in is described in Close in the specification:

  • after close, a receive returns the remaining buffered values first, then returns the zero value of the element type immediately and forever;

  • the two-value receive form v, ok := <-ch reports whether the value was really sent: ok is true for a delivered value and false for the zero value produced by a closed, drained channel. Use it when zero values are valid data, or to detect completion;

  • for v := range ch repeats until the channel is closed and drained. Ranging over a channel yields at most one iteration variable (a compile error shows below), and ranging over a nil channel blocks forever;

  • close of a nil channel, close of an already closed channel, and sending on a closed channel all panic.

package main

import "fmt"

func main() {
	ch := make(chan int, 2)
	ch <- 10
	ch <- 20
	close(ch)

	v, ok := <-ch
	fmt.Println(v, ok)
	v, ok = <-ch
	fmt.Println(v, ok)
	v, ok = <-ch // closed and drained: zero value, ok == false
	fmt.Println(v, ok)

	var done chan struct{} = make(chan struct{})
	close(done)
	_, open := <-done
	fmt.Println("open:", open)

	words := make(chan string, 3)
	words <- "a"
	words <- "b"
	words <- "c"
	close(words)
	for w := range words {
		fmt.Print(w, " ")
	}
	fmt.Println()
}
10 true
20 true
0 false
open: false
a b c

Several things follow from those rules:

  1. Closing is optional. A channel that is no longer referenced is garbage-collected whether open or closed. Close a channel only when a receiver needs to learn that the stream is finished, typically because it is ranging over it. A channel used purely for hand-off, such as the res channel in the timeout example, need not be closed.

  2. The sender closes, never the receiver. A receiver cannot know whether another send is about to happen, and a send on a closed channel panics. If a receiver wants the sender to stop, it signals through a separate channel (see for-select loops) or through a Context.

  3. With several senders, none of them closes. Count them with a sync.WaitGroup and have one dedicated goroutine close the channel after wg.Wait() returns, as merge does below.

  4. A closed channel is a broadcast. Every current and future receiver proceeds at once. This is why chan struct{} plus close is the standard "stop" or "ready" signal.

package main

import (
	"fmt"
	"slices"
	"sync"
)

func gen(prefix string, n int) <-chan string {
	out := make(chan string)
	go func() {
		defer close(out) // the sender that owns the channel closes it
		for i := range n {
			out <- fmt.Sprintf("%s-%d", prefix, i)
		}
	}()
	return out
}

// merge has several senders on one channel, so none of them may close it.
// A dedicated goroutine closes it once every sender has finished.
func merge(ins ...<-chan string) <-chan string {
	out := make(chan string)
	var wg sync.WaitGroup
	for _, in := range ins {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for v := range in {
				out <- v
			}
		}()
	}
	go func() {
		wg.Wait()
		close(out)
	}()
	return out
}

func main() {
	var all []string
	for v := range merge(gen("dune", 2), gen("emma", 3), gen("ulysses", 1)) {
		all = append(all, v)
	}
	slices.Sort(all) // the interleaving is not deterministic, the set of values is
	fmt.Println(all)
}
[dune-0 dune-1 emma-0 emma-1 emma-2 ulysses-0]

Compile errors around channel operations

Misusing the channel statements gives these real compiler messages. Each numbered error comes from a deliberately wrong line in miscbad/main.go: ranging with two variables, a select case that is not a communication, two default clauses, close of a non-channel and a send on a slice.

package main

func main() {
	ch := make(chan int, 1)
	for i, v := range ch {
		_, _ = i, v
	}
	select {
	case ch:
	default:
	default:
	}
	close(42)
	var s []int
	s <- 1
}
# example.com/shelf/miscbad
miscbad/main.go:5:9: range over ch (variable of type chan int) permits only one iteration variable
miscbad/main.go:9:7: select case must be send or receive (possibly with assignment)
miscbad/main.go:11:2: multiple defaults (first at miscbad/main.go:10:2)
miscbad/main.go:13:8: invalid operation: cannot close non-channel 42 (untyped int constant)
miscbad/main.go:15:2: invalid operation: cannot send to non-channel []int s (variable of type []int)

Channels and iterators

Since Go 1.23 the standard way to expose a sequence is an iter.Seq (Iterators and Range-over-Func), and you can wrap a channel in one so callers use an ordinary range loop. The wrapper stops reading when the caller breaks but, true to the rule above, does not close the channel: the sender still owns that.

package main

import (
	"fmt"
	"iter"
)

// Drain adapts a channel to an iterator. Breaking out of the loop stops reading,
// but it does not close the channel: closing remains the sender's job.
func Drain[T any](ch <-chan T) iter.Seq[T] {
	return func(yield func(T) bool) {
		for v := range ch {
			if !yield(v) {
				return
			}
		}
	}
}

func main() {
	ch := make(chan int, 5)
	for i := range 5 {
		ch <- i * 10
	}
	close(ch)
	for v := range Drain(ch) {
		if v > 20 {
			break
		}
		fmt.Println(v)
	}
	fmt.Println("left in channel:", len(ch))
}
0
10
20
left in channel: 1

Source: iter

The channel axioms

The following table is the single most useful thing to memorise about channels. It lists what each operation does for every state a channel can be in. "Blocks forever" means the goroutine is parked with no way to be woken up unless another goroutine changes the situation; for a nil channel nothing ever can, because nobody can send on a channel they cannot reach.

Operation

nil channel

open channel

closed channel

send ch <- v

blocks forever

unbuffered: blocks until a receiver takes it; buffered: blocks while the buffer is full

panics: send on closed channel

receive <-ch

blocks forever

blocks until a value is available

returns buffered values first, then the zero value at once; ok is false

close(ch)

panics: close of nil channel

succeeds; wakes all waiting receivers

panics: close of closed channel

len(ch), cap(ch)

0, 0

queued values, capacity

remaining buffered values, capacity

for range ch

blocks forever

loops until the channel is closed and drained

drains the buffer, then ends

select receive case

never ready (case disabled)

ready when a value is available

always ready, yields the zero value

select send case

never ready (case disabled)

ready when a receiver waits or the buffer has room

always ready, and running it panics

These rules, as the specification states them, cover only a few sentences; the table is the consequence of reading them together. The program below exercises every row that does not block, recovering each panic so that all of them print. The messages are the exact run-time panic values.

package main

import "fmt"

func try(name string, f func()) {
	defer func() {
		if r := recover(); r != nil {
			fmt.Printf("%-24s panic: %v\n", name, r)
		}
	}()
	f()
	fmt.Printf("%-24s ok\n", name)
}

func main() {
	var nilCh chan int
	closed := make(chan int, 1)
	close(closed)
	open := make(chan int, 1)

	try("send on closed", func() { closed <- 1 })
	try("close closed", func() { close(closed) })
	try("close nil", func() { close(nilCh) })
	try("receive from closed", func() { fmt.Println(<-closed) })
	try("len/cap of nil", func() { fmt.Println(len(nilCh), cap(nilCh)) })
	try("send to open buffered", func() { open <- 1 })
	try("close open", func() { close(open) })
	n := -1
	try("make with negative size", func() { _ = make(chan int, n) })

	select {
	case v := <-nilCh:
		fmt.Println("got", v)
	case nilCh <- 1:
		fmt.Println("sent")
	default:
		fmt.Println("nil channel: neither send nor receive is ready")
	}
}
send on closed           panic: send on closed channel
close closed             panic: close of closed channel
close nil                panic: close of nil channel
0
receive from closed      ok
0 0
len/cap of nil           ok
send to open buffered    ok
close open               ok
make with negative size  panic: makechan: size out of range
nil channel: neither send nor receive is ready

The two panics for a closed channel show a design decision worth knowing: closing is not idempotent on purpose. A second close signals that two goroutines each believed they owned the channel, which is a bug the runtime refuses to hide. If a cleanup path really can run twice, protect the close with a sync.Once.

Negative capacity

A constant negative capacity is rejected by the compiler; the same value computed at run time panics, as the make with negative size line above shows. This is the compile-time form:

package main

func main() {
	_ = make(chan int, -1)
}
# example.com/shelf/negconst
negconst/main.go:4:21: invalid argument: index -1 (constant of type int) must not be negative

Select

select waits on several communications at once. It looks like switch, but each case is a send or a receive and the choice is made at run time by readiness (Select statements). This is a syntax summary of the case forms, not a complete program:

select {
case v := <-in:      // receive, binding a new variable
case v, ok := <-in:  // receive with the comma-ok form
case x = <-in:       // receive assigning to an existing variable
case out <- result:  // send
default:             // optional, runs when no case is ready
}

The specification’s execution rules, in order:

  1. For each case, the channel operand and, for a send, the value expression are evaluated exactly once, in source order, when entering the select. Side effects there always happen, even for cases that are not chosen.

  2. If one or more communications can proceed, one of them is chosen by a uniform pseudo-random selection.

  3. Otherwise, if there is a default case, it runs.

  4. Otherwise the goroutine blocks until a communication can proceed. A select with no cases at all, select {}, blocks forever.

  5. The chosen communication runs, then the case’s statement list, and the select ends. Exactly one case runs.

Flow of a select statement: evaluate the cases once, pick a ready case at random, run default if none is ready, otherwise park until one is

There is no priority: listing a case first does not make it more likely. The next example proves the randomness without depending on the exact numbers, which differ in every run.

package main

import "fmt"

func main() {
	a := make(chan int, 1000)
	b := make(chan int, 1000)
	for i := range 1000 {
		a <- i
		b <- i
	}

	var fromA, fromB int
	for range 1000 {
		select { // both cases are always ready: the choice is uniformly pseudo-random
		case <-a:
			fromA++
		case <-b:
			fromB++
		}
	}
	fmt.Println("total:", fromA+fromB)
	fmt.Println("both cases were chosen:", fromA > 0 && fromB > 0)
	fmt.Println("neither starved:", fromA > 300 && fromB > 300)
}
total: 1000
both cases were chosen: true
neither starved: true

Practical consequences:

  • the randomness is a fairness mechanism against starvation, not a feature to build logic on. If one channel must be served first, check it in a nested non-blocking select before the main one;

  • a select with a single case and no default is the same as the plain operation, so it is pointless; a select with one case and a default is the idiom for a non-blocking operation;

  • break inside a select leaves the select, not an enclosing for. This is the commonest bug in for-select loops and is shown below;

  • the statement list of a case runs in the goroutine that executes the select, so a long case body delays the next iteration, and the other channels just wait.

Default: non-blocking operations

A default clause turns blocking operations into attempts. It is how to implement "try to send, drop if full" for metrics or events, and "poll without waiting".

package main

import "fmt"

func trySend(ch chan<- string, s string) bool {
	select {
	case ch <- s:
		return true
	default:
		return false
	}
}

func tryRecv(ch <-chan string) (string, bool) {
	select {
	case s := <-ch:
		return s, true
	default:
		return "", false
	}
}

func main() {
	ch := make(chan string, 1)
	_, ok := tryRecv(ch)
	fmt.Println("recv from empty:", ok)
	fmt.Println("send 1:", trySend(ch, "Dune"))
	fmt.Println("send 2:", trySend(ch, "Emma"))
	s, ok := tryRecv(ch)
	fmt.Println("recv:", s, ok)
}
recv from empty: false
send 1: true
send 2: false
recv: Dune true

A default inside a loop turns it into a busy-wait that burns a CPU core. Add default only when the goroutine has something else useful to do on each pass; otherwise leave it out and let the select park.

Timeouts with time.After

Adding a case that receives from time.After(d) bounds how long the select waits. The helper returns a channel that receives the current time once, after at least d. Note that time.After neither closes the channel nor sends more than one value.

package main

import (
	"fmt"
	"time"
)

func lookup(delay time.Duration) <-chan string {
	out := make(chan string, 1) // buffered: the goroutine never blocks if nobody listens
	go func() {
		time.Sleep(delay)
		out <- "Dune: 612 pages"
	}()
	return out
}

func main() {
	for _, delay := range []time.Duration{10 * time.Millisecond, 300 * time.Millisecond} {
		select {
		case res := <-lookup(delay):
			fmt.Printf("delay %v: %s\n", delay, res)
		case <-time.After(100 * time.Millisecond):
			fmt.Printf("delay %v: timed out\n", delay)
		}
	}
}
delay 10ms: Dune: 612 pages
delay 300ms: timed out

Two details of this example are deliberate. The producer’s channel has capacity 1, so when the caller gives up the goroutine can still complete its send and exit instead of blocking for ever (a leak). And each timeout comes from time.After, which is fine here because the loop runs twice; for a hot loop use a reusable time.Timer, below.

Timers since Go 1.23

Go 1.23 changed how timers work (Go 1.23 release notes, "Timer changes"), and Go 1.27 made the change permanent by removing the asynctimerchan GODEBUG switch (Go 1.27 release notes). The rules now are:

  • a time.Timer or time.Ticker that nothing refers to any more can be garbage-collected immediately, even if it was never stopped. The old advice that time.After in a loop leaks timers until they fire no longer applies to modules whose go.mod says go 1.23 or later;

  • the channel of a timer is unbuffered (synchronous): cap(t.C) and len(t.C) are 0;

  • after t.Stop() or t.Reset(d) returns, no stale value from the previous setting will be received. The old idiom if !t.Stop() { <-t.C } to drain a timer is therefore unnecessary and, since Stop now reports false only when the value was already received or the timer was stopped before, can may even block;

  • Stop returns true if the timer had not yet been delivered, which includes a timer that has expired but whose value nobody has received.

package main

import (
	"fmt"
	"time"
)

func main() {
	t := time.NewTimer(10 * time.Millisecond)
	fmt.Println("len:", len(t.C), "cap:", cap(t.C)) // Go 1.23+: always 0 and 0

	time.Sleep(50 * time.Millisecond) // the timer has fired, nobody has received

	// Reset after expiry: since Go 1.23 no stale value is left behind.
	t.Reset(200 * time.Millisecond)
	start := time.Now()
	<-t.C
	waited := time.Since(start)
	fmt.Println("waited about the new duration:", waited >= 150*time.Millisecond)

	// Stop on an expired, unreceived timer also discards the pending value.
	t2 := time.NewTimer(10 * time.Millisecond)
	time.Sleep(50 * time.Millisecond)
	fmt.Println("Stop reported active:", t2.Stop())
	select {
	case <-t2.C:
		fmt.Println("stale value received")
	default:
		fmt.Println("no stale value")
	}
}
len: 0 cap: 0
waited about the new duration: true
Stop reported active: true
no stale value

Source: time: Timer

A hot loop that needs an inactivity timeout should still reuse one timer rather than allocate one per iteration, using Reset to rearm it; with Go 1.23 semantics this is also safe without draining. The longer time API (tickers, AfterFunc, Duration arithmetic) is on Time and Timers.

Nil channels disable cases

Because a nil channel is never ready, assigning nil to a channel variable switches that select case off. This is how to read from two sources until both are exhausted without spinning on a closed channel, whose receive is always ready:

package main

import "fmt"

func gen(vals ...int) <-chan int {
	ch := make(chan int)
	go func() {
		defer close(ch)
		for _, v := range vals {
			ch <- v
		}
	}()
	return ch
}

func main() {
	a, b := gen(1, 2, 3), gen(10, 20)
	sum, reads := 0, 0
	for a != nil || b != nil {
		select {
		case v, ok := <-a:
			if !ok {
				a = nil // a nil channel is never ready: this case is disabled
				continue
			}
			sum += v
			reads++
		case v, ok := <-b:
			if !ok {
				b = nil
				continue
			}
			sum += v
			reads++
		}
	}
	fmt.Println("reads:", reads, "sum:", sum)
}
reads: 5 sum: 36

The same trick toggles a send case on and off: set the outgoing channel variable to nil while there is nothing to send, and to the real channel when there is, so the select waits for exactly what it can do.

For-select loops

Long-lived goroutines usually run a for loop around a select that includes a "stop" case. Closing a channel wakes every receiver at once, which makes a chan struct{} the cheapest broadcast.

package main

import (
	"fmt"
	"sync"
)

func main() {
	jobs := make(chan int)
	done := make(chan struct{})
	var wg sync.WaitGroup
	var mu sync.Mutex
	handled := 0

	for range 3 {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for {
				select {
				case <-done:
					return
				case j := <-jobs:
					_ = j
					mu.Lock()
					handled++
					mu.Unlock()
				}
			}
		}()
	}

	for i := range 9 {
		jobs <- i
	}
	close(done) // a closed channel is a broadcast: every waiting worker wakes up
	wg.Wait()
	fmt.Println("handled:", handled)
}
handled: 9

The loop is correct only because wg.Wait() makes sure every worker has seen done before main prints, and because the counter is protected by a mutex. Running it with go run -race produces no report.

In real code the stop signal is almost always a context.Context: its Done() channel is the same closed-channel broadcast with a reason attached; see Context.

break leaves the select, not the loop

Inside select the keywords break and continue follow the normal rules: break leaves the innermost for, switch or select, so a bare break in a case ends only the select. To leave the loop use a label or return:

package main

import "fmt"

func main() {
	ch := make(chan int, 5)
	for i := range 5 {
		ch <- i
	}
	close(ch)

	n := 0
loop:
	for {
		select {
		case v, ok := <-ch:
			if !ok {
				break loop // a bare break would only leave the select
			}
			if v == 3 {
				break // leaves the select, not the for
			}
			n++
		}
	}
	fmt.Println("counted:", n)
}
counted: 4

The unlabeled break for v == 3 skipped the increment for that value (so four of five values were counted) but kept looping; only break loop ended the for.

Reading channels dynamically with reflect.Select

When the number of channels is only known at run time, reflect.Select takes a slice of reflect.SelectCase and returns the index chosen, the received value and the comma-ok flag. A zero Chan value (or a nil channel) disables a case exactly as in a literal select. Prefer ordinary select or a fan-in goroutine per channel (as merge above) when the count is small; reflection is slower and loses type safety. See Reflection.

package main

import (
	"fmt"
	"reflect"
)

func main() {
	chans := make([]chan string, 3)
	cases := make([]reflect.SelectCase, len(chans))
	for i := range chans {
		chans[i] = make(chan string, 1)
		cases[i] = reflect.SelectCase{Dir: reflect.SelectRecv, Chan: reflect.ValueOf(chans[i])}
	}
	chans[1] <- "Emma"

	chosen, v, ok := reflect.Select(cases)
	fmt.Println("case:", chosen, "value:", v, "ok:", ok)

	close(chans[2])
	cases = append(cases[:0:0], cases...)
	cases[1].Chan = reflect.ValueOf((chan string)(nil)) // disable case 1 with a nil channel
	chosen, v, ok = reflect.Select(cases)
	fmt.Println("case:", chosen, "value:", v.String() == "", "ok:", ok)
}
case: 1 value: Emma ok: true
case: 2 value: true ok: false

Source: reflect: Select

Channels of channels and request/reply

Channels are first-class values, so a request can carry the channel the answer should come back on. This gives you a goroutine that owns some state (here the stock of each book) and serialises all access through messages, with no mutex: nobody else holds a reference to the map. The idea is in the Effective Go section Channels of channels.

package main

import (
	"fmt"
	"sync"
)

type request struct {
	isbn  string
	reply chan<- int // each request carries its own reply channel
}

// stock is owned by exactly one goroutine; nobody else touches the map.
func stockKeeper(adds <-chan string, reads <-chan request) {
	stock := map[string]int{}
	for adds != nil || reads != nil {
		select {
		case isbn, ok := <-adds:
			if !ok {
				adds = nil
				continue
			}
			stock[isbn]++
		case r, ok := <-reads:
			if !ok {
				reads = nil
				continue
			}
			r.reply <- stock[r.isbn]
		}
	}
}

func main() {
	adds := make(chan string)
	reads := make(chan request)
	go stockKeeper(adds, reads)

	var wg sync.WaitGroup
	for range 8 {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for range 100 {
				adds <- "978-0-441-17271-9"
			}
		}()
	}
	wg.Wait()

	reply := make(chan int)
	reads <- request{"978-0-441-17271-9", reply}
	fmt.Println("copies:", <-reply)
	close(adds)
	close(reads)
}
copies: 800

The example ran with -race and printed no report, because every access to stock happens inside the single stockKeeper goroutine. The request struct uses the direction chan<- int for the reply, so the keeper cannot accidentally read from it.

Deadlocks

A deadlock is a state where goroutines wait for each other and none can proceed. If every goroutine in the process is blocked on a channel operation, a sync primitive or an empty select, the runtime notices and aborts with a fatal error. This is not a panic, so recover cannot catch it, and the exit status is 2. The first three lines of the output of each program are shown; the rest is a stack trace with machine-specific paths.

A send on an unbuffered channel that nobody receives from:

package main

import "fmt"

func main() {
	ch := make(chan int) // unbuffered, nobody receives
	ch <- 1
	fmt.Println("unreachable")
}
fatal error: all goroutines are asleep - deadlock!

goroutine 1 [chan send]:

A receive on a nil channel, with the goroutine state telling you exactly why:

package main

import "fmt"

func main() {
	var ch chan int // nil: blocks forever
	fmt.Println(<-ch)
}
fatal error: all goroutines are asleep - deadlock!

goroutine 1 [chan receive (nil chan)]:

And the same condition reached through a sync.WaitGroup: every goroutine sent on a channel with no receiver, so main waits for ever for goroutines that wait for main:

package main

import (
	"fmt"
	"sync"
)

func main() {
	var wg sync.WaitGroup
	ch := make(chan int)
	for i := range 3 {
		wg.Add(1)
		go func() {
			defer wg.Done()
			ch <- i // nobody ever receives
		}()
	}
	wg.Wait()
	fmt.Println(len(ch))
}
fatal error: all goroutines are asleep - deadlock!

goroutine 1 [sync.WaitGroup.Wait]:

Know the limits of the detector, because it is much weaker than its message suggests:

  • it fires only when all goroutines are blocked. A single goroutine that is runnable, sleeping in a timer, waiting on a network connection or a syscall, or running cgo code means the program is not "asleep", so a real deadlock among the other goroutines goes unreported and the program just hangs. A web server with an idle listener never trips it;

  • it cannot see a partial deadlock or leak, in which some goroutines are stuck for ever while others carry on;

  • hence tools beyond the runtime: go test -timeout, the goroutine profile and, since Go 1.27, the goroutine-leak profile (The Go Blog: Goroutine leak profiles). See Profiling, Tracing and Diagnostics.

The usual causes, with the fix for each:

Cause Fix

send on an unbuffered channel with no receiver ever running

start the receiver first or in a goroutine; give a result channel capacity 1 if the sender must not wait

receiver ranges over a channel nobody closes

the (sole) sender closes it, or the last of several senders via a WaitGroup goroutine

wg.Wait() while the goroutines are blocked on a channel send

receive in the waiting goroutine, or close the channel from a goroutine that waits on the group

receive from a nil channel, usually an uninitialised struct field

create it with make in the constructor; nil is only intentional inside a select

two goroutines each wait for the other’s message

agree a protocol order, or use a buffered or select-based design with a timeout

Goroutine leaks

A goroutine blocked for ever on a channel is a leak: it holds its stack, everything it references and, with it, the channels it waits on. The runtime does not collect it and, as above, usually does not report it. The classic source is "first result wins" with an unbuffered channel: the losers never find a receiver.

package main

import (
	"fmt"
	"runtime"
	"time"
)

func first(n int, buffer int) string {
	res := make(chan string, buffer)
	for i := range n {
		go func() { res <- fmt.Sprintf("replica %d", i) }()
	}
	return (<-res)[:7]
}

func main() {
	base := runtime.NumGoroutine()

	_ = first(5, 0) // unbuffered: the four losers block on send forever
	time.Sleep(50 * time.Millisecond)
	fmt.Println("leaked with unbuffered:", runtime.NumGoroutine()-base)

	base = runtime.NumGoroutine()
	_ = first(5, 5) // buffered for every sender: all goroutines finish
	time.Sleep(50 * time.Millisecond)
	fmt.Println("leaked with buffered:", runtime.NumGoroutine()-base)
}
leaked with unbuffered: 4
leaked with buffered: 0

Four goroutines were stuck after the unbuffered call; with capacity for every sender they all finished. The preventions, in order of preference:

  1. give every goroutine a way to be told to stop (a context.Context or a done channel) and select on it in every blocking operation, not only in the main loop;

  2. make sure each goroutine’s blocking operation has a counterpart that is guaranteed to run, or a buffer that makes it non-blocking;

  3. close channels from the sender so that range loops end;

  4. in tests, assert on runtime.NumGoroutine() as done above, or inspect the goroutine profile; and with testing/synctest a bubble that leaves goroutines blocked reports a deadlock.

Data races, channels and -race

Channels are not a magic cure: they synchronise only the goroutines that use them for the shared data. Reading or writing the same variable from two goroutines without a happens-before edge between them is a data race, whose outcome is undefined. The race detector (go run -race, go build -race, go test -race) instruments memory accesses and reports races it observes. The counter below is a race: four goroutines each do a non-atomic read-modify-write.

package main

import (
	"fmt"
	"sync"
)

func main() {
	var wg sync.WaitGroup
	loans := 0
	for range 4 {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for range 1000 {
				loans++ // unsynchronized read-modify-write
			}
		}()
	}
	wg.Wait()
	fmt.Println("done")
}

The detector’s report contains addresses, goroutine numbers and stack traces that differ per run, and the number of reports can vary too. The command below keeps only the stable lines: the first report marker, the program’s own output (the program still terminates) and the exit status, which is 66 because the detector found a race:

DATA RACE
done
exit status 66

The fix does not need a lock: let one goroutine own the counter, as stockKeeper does, or protect it with a sync.Mutex or an atomic. The detector finds only races that actually execute, so run tests with -race in CI and exercise concurrent paths. It slows programs several-fold and needs more memory, so it is not for production binaries. Details are on Memory Model and Data Races.

Loop variables and goroutines

Before Go 1.22 a for loop shared one variable across iterations, so goroutines started in the loop commonly all saw the last value, and code was littered with v := v copies. Modules that declare go 1.22 or later get a fresh variable per iteration, so the closure below is correct as written:

package main

import (
	"fmt"
	"slices"
)

func main() {
	results := make(chan int)
	for i := range 5 {
		go func() { results <- i * i }() // Go 1.22+: every iteration has its own i
	}
	var got []int
	for range 5 {
		got = append(got, <-results)
	}
	slices.Sort(got) // arrival order is not deterministic
	fmt.Println(got)
}
[0 1 4 9 16]

This applies per module through the go line of go.mod (the example module says go 1.27); a dependency that still says go 1.21 keeps the old semantics for its own files. See Toolchain Versions and Compatibility and the loop-variable discussion in Control Flow.

Testing channel code deterministically

Timeouts are awkward to test with real clocks: either the test sleeps for the whole duration, or the margins are so tight that it is flaky. The testing/synctest package (experimental in Go 1.24, generally available in Go 1.25) runs the code in an isolated "bubble" with a fake clock that advances only when every goroutine in the bubble is blocked. A five-minute timeout test then finishes instantly and exactly.

The function under test (in main.go):

package main

import (
	"fmt"
	"time"
)

// Fetch waits for a result but gives up after limit.
func Fetch(res <-chan string, limit time.Duration) (string, error) {
	select {
	case s := <-res:
		return s, nil
	case <-time.After(limit):
		return "", fmt.Errorf("fetch: no result after %v", limit)
	}
}

func main() {
	res := make(chan string, 1)
	res <- "Dune"
	fmt.Println(Fetch(res, time.Second))
}

And the test file (main_test.go):

package main

import (
	"testing"
	"testing/synctest"
	"time"
)

func TestFetchTimesOut(t *testing.T) {
	synctest.Test(t, func(t *testing.T) {
		res := make(chan string) // nobody ever sends
		start := time.Now()
		_, err := Fetch(res, 5*time.Minute) // fake clock: returns immediately in real time
		if err == nil {
			t.Fatal("expected a timeout")
		}
		if got := time.Since(start); got != 5*time.Minute {
			t.Fatalf("elapsed %v, want 5m0s", got)
		}
	})
}

func TestFetchResult(t *testing.T) {
	synctest.Test(t, func(t *testing.T) {
		res := make(chan string)
		go func() {
			time.Sleep(2 * time.Second)
			res <- "Emma"
		}()
		got, err := Fetch(res, 5*time.Second)
		if err != nil || got != "Emma" {
			t.Fatalf("got %q, %v", got, err)
		}
	})
}
--- PASS: TestFetchTimesOut (0.00s)
--- PASS: TestFetchResult (0.00s)
PASS

The elapsed time inside the bubble is exactly 5m0s even though the test ran in milliseconds. synctest.Wait and the bubble’s deadlock detection are covered on Benchmarks, Fuzzing and synctest.

Design rules of thumb

  • Who owns the channel decides who closes it. Document it in the function that creates the channel.

  • Return receive-only channels, accept directional parameters. The compiler then enforces the protocol.

  • Prefer synchronous APIs. Expose a plain function and let the caller add go; an API that returns a channel forces every caller into concurrency (see Goroutines and the Scheduler).

  • Every blocking send or receive in a long-lived goroutine needs a way out: a select on a done channel or context.

  • Capacity is a design decision, not a tuning knob. 0 or 1 unless you can name what the extra slots are for.

  • Use a mutex for shared state, a channel for ownership transfer and signalling.

  • Do not close to signal "I am done receiving". Signal separately.

  • Do not share a nil channel by accident. A struct with a channel field is useless until it is initialised.

  • Do not poll with default in a tight loop.

Where the books are outdated

  • Bodner, chapter 10 teaches the behaviour table for nil, open and closed channels, select, the for-select loop and timeouts with time.After. The table is still correct and is reproduced above in the reference’s own words. What changed: timers are now garbage-collected when unreferenced and have synchronous channels (Go 1.23, permanent in Go 1.27), the v := v copy before go func() is unnecessary (Go 1.22), sync.WaitGroup.Go (Go 1.25) removes the Add/Done bookkeeping around goroutine launches, and the race detector, vet and testing/synctest are better tools for finding and testing the problems the chapter describes.

  • Vivien, chapter 9 catalogues channel semantics (unbuffered, buffered, deadlock, directional, len/cap, close and comma-ok) in a way that matches the current language. The chapter describes the channel returned by time.After as being closed after it fires; it is not: it carries exactly one value and is never closed, so ranging over it does not terminate. The chapter predates context (Go 1.7), which replaced the hand-written done channel for most cancellation, and predates asynchronous preemption (Go 1.14) and the container-aware GOMAXPROCS default (Go 1.25). Its examples that rely on main returning without waiting, or on time.Sleep to let goroutines finish, are not reliable and should use a WaitGroup or a channel.

Version notes

  • Go 1.0: channels, select, close, comma-ok receive and directional channels (the spec has not changed here since).

  • Go 1.7: the context package.

  • Go 1.14: asynchronous preemption, so a for { select { default: } } spin no longer starves the scheduler.

  • Go 1.22: per-iteration loop variables for modules with go 1.22; range over integers.

  • Go 1.23: timers and tickers are garbage-collected when unreferenced and use synchronous channels; iter.Seq and range-over-func.

  • Go 1.24: testing/synctest as an experiment.

  • Go 1.25: testing/synctest generally available; sync.WaitGroup.Go.

  • Go 1.27: the asynctimerchan GODEBUG setting is removed (timer channels are always synchronous); the goroutine-leak profile is generally available.