Select in Go With Timeouts Defaults and Priorities
How the Go select statement works: random choice among ready cases, default, timeouts, priorities, loops, the break trap and what select costs.

A channel operation blocks until it can proceed, which is fine when a goroutine has one thing to wait for. Real goroutines usually wait for several: a result or a timeout, a job or a shutdown signal, input from two sources. select is the statement for that. It looks like a switch whose cases are communications, and it is what turns channels from a pipe into a coordination tool.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64)
This guide covers the semantics exactly, then the five patterns that account for most uses: timeout, non-blocking, priority, the loop and the merge. It follows channels explained and continues into nil channels, context and the timeout pattern. Everything ran on Go 1.25.6.
The semantics
package main
import (
"fmt"
"time"
)
func main() {
fast := make(chan string)
slow := make(chan string)
go func() {
time.Sleep(10 * time.Millisecond)
fast <- "fast"
}()
go func() {
time.Sleep(200 * time.Millisecond)
slow <- "slow"
}()
select {
case msg := <-fast:
fmt.Println("received", msg)
case msg := <-slow:
fmt.Println("received", msg)
}
}received fastselect evaluates all the channel expressions and send values once, on entry. It then blocks until at least one case can proceed, runs that one, and finishes. Here fast is ready first, so its case runs and slow is never read. Each select executes exactly one case, once. To keep handling events you wrap it in a for.
A case can be a receive (case v := <-ch, case v, ok := <-ch, or case <-ch when the value does not matter), a send (case ch <- v), or default.
Several ready cases: a random choice
When more than one case is ready, Go picks one uniformly at random. It does not take the first in source order, and it does not rotate. This is deliberate: it prevents one busy channel from starving the others.
package main
import "fmt"
func main() {
a := make(chan int, 1000)
b := make(chan int, 1000)
for range 1000 {
a <- 1
b <- 1
}
var fromA, fromB int
for range 1000 {
select {
case <-a:
fromA++
case <-b:
fromB++
}
}
fmt.Println("a chosen roughly half the time:", fromA > 400 && fromA < 600)
fmt.Println("b chosen roughly half the time:", fromB > 400 && fromB < 600)
}a chosen roughly half the time: true
b chosen roughly half the time: trueBoth channels always have data, and the counts come out near 500 each. Code must never depend on which ready case wins. If you need "A before B", you have to say so explicitly, as in the priority pattern below.
default: never block
A default case runs when no other case is ready, which makes the whole statement non-blocking:
package main
import "fmt"
func main() {
jobs := make(chan int, 1)
// non-blocking send
select {
case jobs <- 1:
fmt.Println("queued")
default:
fmt.Println("queue full")
}
select {
case jobs <- 2:
fmt.Println("queued")
default:
fmt.Println("queue full, dropping job 2")
}
// non-blocking receive
select {
case j := <-jobs:
fmt.Println("took job", j)
default:
fmt.Println("no job waiting")
}
select {
case j := <-jobs:
fmt.Println("took job", j)
default:
fmt.Println("no job waiting")
}
}queued
queue full, dropping job 2
took job 1
no job waitingThis is how you drop work under load instead of blocking a request handler (load shedding), poll a channel from a loop that has other work to do, or try a lock-like operation. Be careful with default inside a for: a select with default in a tight loop never blocks, so the loop spins at full CPU. If you wanted to wait, remove the default.
Timeouts
The classic use is to bound a wait. A case on time.After fires after a duration:
package main
import (
"fmt"
"time"
)
func slowOperation() <-chan string {
out := make(chan string, 1) // buffered so the goroutine can exit if we time out
go func() {
time.Sleep(200 * time.Millisecond)
out <- "result"
}()
return out
}
func main() {
select {
case r := <-slowOperation():
fmt.Println(r)
case <-time.After(50 * time.Millisecond):
fmt.Println("timed out")
}
}timed outThe operation takes 200 ms and the timeout is 50, so the program prints timed out. The channel is buffered with capacity 1 so the producing goroutine can finish and exit instead of blocking forever on a send nobody reads (buffered channels explains why). In a service, prefer a context deadline over a hand-made timer: case <-ctx.Done() carries both cancellation and deadline, and it propagates down the call chain (context with timeout).
A note on timers in loops. time.After creates a timer each time it is evaluated. Before Go 1.23 an unreferenced timer was not garbage collected until it fired, so calling time.After(time.Minute) inside a hot loop piled up pending timers. Go 1.23 changed the implementation so that unreferenced timers are collected, and the leak is gone for modules on go 1.23 or later, but the allocation per iteration remains. For a loop that handles many events, create one time.Timer and reset it (the time.After leak).
Priority: build it yourself
Because the choice among ready cases is random, select has no priorities. When one channel must win, check it first with a non-blocking select, and fall through to the combined one:
package main
import "fmt"
func main() {
urgent := make(chan string, 2)
normal := make(chan string, 2)
urgent <- "urgent 1"
normal <- "normal 1"
urgent <- "urgent 2"
normal <- "normal 2"
for range 4 {
select {
case m := <-urgent:
fmt.Println(m)
continue
default:
}
select {
case m := <-urgent:
fmt.Println(m)
case m := <-normal:
fmt.Println(m)
}
}
}urgent 1
urgent 2
normal 1
normal 2The first select drains urgent whenever it has anything, using default to move on if not. Only when urgent is empty does the second select wait on both. Urgent messages come out first, deterministically. The price is a second check on every iteration and a small window where an urgent item arrives between the two selects and waits for the next pass. For strict priority across more than two channels, nest the pattern, or use a single channel of a struct that carries a priority and sort on the consumer side.
The loop, and the break trap
Most select statements live inside a for that handles events until something says stop:
package main
import (
"context"
"fmt"
"time"
)
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 55*time.Millisecond)
defer cancel()
ticker := time.NewTicker(20 * time.Millisecond)
defer ticker.Stop()
ticks := 0
loop:
for {
select {
case <-ticker.C:
ticks++
fmt.Println("tick", ticks)
case <-ctx.Done():
fmt.Println("stopping:", ctx.Err())
break loop
}
}
fmt.Println("done after", ticks, "ticks")
}tick 1
tick 2
stopping: context deadline exceeded
done after 2 ticksNotice break loop. A plain break inside a select exits the select, not the for, so without the label the loop would continue forever. This is a classic source of busy loops and goroutines that never end. The alternatives are return from the function (often cleanest, with the loop in its own function) or setting a flag that the for condition checks. The break label article covers the syntax.
Merging inputs
Combining several channels into one is a loop around a select, and nil channels turn off the ones that have closed:
package main
import (
"fmt"
"sort"
)
func merge(a, b <-chan int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for a != nil || b != nil {
select {
case v, ok := <-a:
if !ok {
a = nil
continue
}
out <- v
case v, ok := <-b:
if !ok {
b = nil
continue
}
out <- v
}
}
}()
return out
}
func gen(vals ...int) <-chan int {
c := make(chan int)
go func() {
defer close(c)
for _, v := range vals {
c <- v
}
}()
return c
}
func main() {
var all []int
for v := range merge(gen(1, 3, 5), gen(2, 4, 6)) {
all = append(all, v)
}
sort.Ints(all)
fmt.Println(all)
}[1 2 3 4 5 6]When a source closes, receiving from it would return zero values forever, so the code sets it to nil and the select stops considering it. The loop ends when both are nil. The order the values arrive in is not fixed, so the example sorts them. For more than a few inputs, reflect.Select can wait on a dynamic number of channels, but at that point a fan-in with a goroutine per input and a shared output is easier to read (fan-in).
Context-aware send and receive helpers
Almost every blocking channel operation in a service should be abandonable. Rather than repeat the two-case select everywhere, write the two small helpers once:
package main
import "context"
// Send delivers v on ch, or gives up when ctx ends.
func Send[T any](ctx context.Context, ch chan<- T, v T) error {
select {
case ch <- v:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
// Recv takes a value from ch, or gives up when ctx ends. ok is false when ch is closed.
func Recv[T any](ctx context.Context, ch <-chan T) (v T, ok bool, err error) {
select {
case v, ok = <-ch:
return v, ok, nil
case <-ctx.Done():
return v, false, ctx.Err()
}
}package main
import (
"context"
"errors"
"testing"
"time"
)
func TestSendGivesUpWhenContextEnds(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
defer cancel()
ch := make(chan int) // nobody receives
if err := Send(ctx, ch, 1); !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("Send = %v, want deadline exceeded", err)
}
}
func TestRecvSeesClose(t *testing.T) {
ch := make(chan string, 1)
ch <- "x"
close(ch)
v, ok, err := Recv(context.Background(), ch)
if v != "x" || !ok || err != nil {
t.Fatalf("first Recv = %q %v %v", v, ok, err)
}
_, ok, err = Recv(context.Background(), ch)
if ok || err != nil {
t.Fatalf("second Recv ok=%v err=%v, want closed", ok, err)
}
}=== RUN TestSendGivesUpWhenContextEnds
--- PASS: TestSendGivesUpWhenContextEnds (0.02s)
=== RUN TestRecvSeesClose
--- PASS: TestRecvSeesClose (0.00s)
PASS
ok example.test/lab 1.286sWith these, a worker's loop reads for { job, ok, err := Recv(ctx, jobs); if err != nil || !ok { return } ... }, and a producer's send cannot hang past cancellation. The tests show both exits: the context ending, and the channel closing. When a package has many blocking calls, a pair of helpers beats forty hand-written selects, and the one place that defines "what we do on cancellation" is easy to review.
What a select costs
A select does more work than a plain channel operation: it must lock every channel involved, in a fixed order, check each case, and enqueue itself on all of them if none is ready. The benchmark below compares a plain receive, a two-case select, and a select with a default on the same buffered channel:
package main
import "testing"
func fill(ch chan int) {
for i := 0; i < cap(ch); i++ {
ch <- i
}
}
func BenchmarkPlainReceive(b *testing.B) {
ch := make(chan int, 1)
for i := 0; i < b.N; i++ {
ch <- i
<-ch
}
}
func BenchmarkSelectTwoCases(b *testing.B) {
ch := make(chan int, 1)
quit := make(chan struct{})
for i := 0; i < b.N; i++ {
ch <- i
select {
case <-ch:
case <-quit:
}
}
}
func BenchmarkSelectWithDefault(b *testing.B) {
ch := make(chan int, 1)
for i := 0; i < b.N; i++ {
ch <- i
select {
case <-ch:
default:
}
}
}goos: darwin
goarch: arm64
pkg: example.test/lab
cpu: Apple M1 Max
BenchmarkPlainReceive-10 48042649 22.92 ns/op 0 B/op 0 allocs/op
BenchmarkPlainReceive-10 50764192 23.16 ns/op 0 B/op 0 allocs/op
BenchmarkPlainReceive-10 50952168 23.18 ns/op 0 B/op 0 allocs/op
BenchmarkSelectTwoCases-10 24145414 50.20 ns/op 0 B/op 0 allocs/op
BenchmarkSelectTwoCases-10 23864350 51.84 ns/op 0 B/op 0 allocs/op
BenchmarkSelectTwoCases-10 23243895 52.76 ns/op 0 B/op 0 allocs/op
BenchmarkSelectWithDefault-10 49759237 23.90 ns/op 0 B/op 0 allocs/op
BenchmarkSelectWithDefault-10 51506383 23.43 ns/op 0 B/op 0 allocs/op
BenchmarkSelectWithDefault-10 49490574 23.02 ns/op 0 B/op 0 allocs/op
PASS
ok example.test/lab 12.071sOn an Apple M1 Max, one send and receive on a buffered channel took about 23 ns plain, about 51 ns through a two-case select, and about 23.5 ns through a select with a default, with no allocations in any of them. So the second case roughly doubles the cost, because the runtime locks both channels, and a default that is taken immediately costs almost nothing extra. Either way it is irrelevant next to any real work, and it is the reason not to put a select in the innermost loop of a numeric computation. The takeaway is the same as for channels generally: use select where you need to wait on several things, and don't use it as a general-purpose control structure.
Reading select in a goroutine dump
A goroutine blocked in a select shows the state select in a dump, or select (no cases) for select {}. The stack frame points at the line of the select, but not at which case it was waiting on, so you read the code to see the channels involved. A cluster of goroutines parked at the same select usually means a shared channel with no sender, a context that was never cancelled, or a done channel that was never closed. The count of such goroutines over time is the clearest sign of a leak (goroutine dumps and leaks).
Fairness, starvation and a busy channel
The random choice among ready cases gives each ready channel an equal chance on each pass, which is fair over time but says nothing about latency for any single message. If one channel is busy and another carries rare, important messages, the rare one is still chosen about half the time when both are ready, so it is not starved. What can starve is the code after the select: if each iteration spends a long time handling a message from the busy channel, messages on the other wait in their channels for the whole duration. Keep the work in a select loop short, or hand it to another goroutine, so the loop returns to the select promptly. The same reasoning is why a done signal belongs in every loop's select: the shutdown message only arrives if the loop gets back to the select.
Choosing the right tool
| You want to | Use |
|---|---|
| Wait for one of several channel events | select |
| Bound a wait | select with ctx.Done() or a timer |
| Try an operation without blocking | select with default |
| Run work and wait for all of it | sync.WaitGroup or errgroup, not a select loop |
| Stop a tree of goroutines | context cancellation |
| Wait on a dynamic number of channels | one goroutine per channel into a shared one, or reflect.Select |
The pattern behind the table is that select is for events, not for orchestration. If you find yourself building a state machine out of selects with a dozen cases, step back: the design may want a small number of goroutines, each with a simple loop, communicating over a few channels.
Testing code with select
A select makes timing part of the behavior, and tests should control the time. Avoid sleeps as synchronisation. Pass the timeout in as a parameter so tests can use milliseconds, send into channels from the test to drive each branch, and assert the outcome of each case separately: the result case, the timeout case and the cancel case. For the timeout case, make the dependency block forever and check the function returns within a generous bound. For cancellation, cancel the context before the call and check that nothing was started. In Go 1.25 the testing/synctest package can run a test in a bubble with a fake clock, which makes timeout tests instant and deterministic (testing with synctest and fake clocks).
Special cases
An empty select {} blocks forever. It is used in main to wait while other goroutines work, and it is a deadlock if they all exit (the deadlock error). A case on a nil channel is never ready, which is a way to switch a case off, as above. A send case evaluates its value whether or not that case is chosen, so case ch <- expensive(): calls expensive() on every entry to the select. Closed channels are always ready for receive, so a loop selecting on a closed channel spins. Only one case runs per entry, so to process several ready events you loop.
A worked example: a worker with three exits
Putting the pieces together, a typical long-running worker has three ways out of its loop: the context ends, the job channel closes, or an idle timer fires after a quiet period. Each is a case. The loop reads jobs while they come, resets its idle timer on each, and returns on any exit. The structure is worth memorising because it recurs everywhere: a for, a select with the work case, the done case and a timer case, and a return or a labeled break on each way out. Written this way, the worker has no hidden states, a reader sees every exit in one place, and a leak is hard to introduce because every blocking point is also a cancellation point.
Mistakes to avoid
breakin afor/selectwithout a label.defaultin a loop meant to wait, producing a busy loop.time.Afterin a loop with a long duration, on a module belowgo 1.23.- Depending on case order or on a particular ready case winning.
- Using a
selectwith a single case and nodefault, which is just a channel operation with extra syntax. - Not stopping a
time.Ticker.
The specification defines the behavior precisely in its section on select statements, including the order in which expressions are evaluated and the uniform random choice, and the Tour's select lesson is a short interactive introduction.


