Skip to content
gohacks.dev
Concurrency

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.

13 min read
Summarize in AI
Flat illustration of one flow fanning out into parallel lanes of goroutines and joining back.

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

main.goGo
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)
	}
}
Text
received fast

select 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.

main.goGo
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)
}
Text
a chosen roughly half the time: true
b chosen roughly half the time: true

Both 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:

main.goGo
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")
	}
}
Text
queued
queue full, dropping job 2
took job 1
no job waiting

This 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:

main.goGo
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")
	}
}
Text
timed out

The 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:

main.goGo
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)
		}
	}
}
Text
urgent 1
urgent 2
normal 1
normal 2

The 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:

main.goGo
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")
}
Text
tick 1
tick 2
stopping: context deadline exceeded
done after 2 ticks

Notice 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:

main.goGo
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)
}
Text
[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:

ctxops.goGo
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()
	}
}
ctxops_test.goGo
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)
	}
}
Text
=== RUN   TestSendGivesUpWhenContextEnds
--- PASS: TestSendGivesUpWhenContextEnds (0.02s)
=== RUN   TestRecvSeesClose
--- PASS: TestRecvSeesClose (0.00s)
PASS
ok  	example.test/lab	1.286s

With 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:

sel_test.goGo
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:
		}
	}
}
Text
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.071s

On 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 toUse
Wait for one of several channel eventsselect
Bound a waitselect with ctx.Done() or a timer
Try an operation without blockingselect with default
Run work and wait for all of itsync.WaitGroup or errgroup, not a select loop
Stop a tree of goroutinescontext cancellation
Wait on a dynamic number of channelsone 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

  • break in a for/select without a label.
  • default in a loop meant to wait, producing a busy loop.
  • time.After in a loop with a long duration, on a module below go 1.23.
  • Depending on case order or on a particular ready case winning.
  • Using a select with a single case and no default, 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.

Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.