Skip to content

Select

select is Go’s way to wait on multiple channel operations simultaneously. It blocks until one of its cases is ready, then executes that case. If multiple cases are ready at the same time, one is chosen at random — this is a deliberate language design choice to avoid starvation.

select {
case v := <-ch1:
fmt.Println("received from ch1:", v)
case ch2 <- 99:
fmt.Println("sent to ch2")
}

A select case can be a send or a receive — whichever operation can proceed without blocking.

Adding a default case makes the select non-blocking. If no channel is ready, the default branch runs immediately:

select {
case v := <-ch:
fmt.Println("got", v)
default:
fmt.Println("nothing ready yet")
}

This is the idiomatic way to poll a channel without blocking.

flowchart TD
  Start["select { ... }"] --> Ready{"Any case ready?"}
  Ready -->|"exactly one ready"| Run["run that case"]
  Ready -->|"multiple ready"| Rand["pick one at random"]
  Ready -->|"none ready"| HasDefault{"default present?"}
  HasDefault -->|yes| Def["run default immediately"]
  HasDefault -->|no| Block["block the goroutine until a case becomes ready"]
select's decision process: random among ready cases, default only when nothing is ready, block only when there is no default

A default case is tempting to reach for whenever you want to “check and move on,” but wrapping it in a bare for loop turns a cheap non-blocking check into a CPU-burning busy-spin:

for {
select {
case v := <-ch:
process(v)
default:
// nothing ready — loop right back around and check again
}
}

With nothing to slow it down, this loop re-evaluates select millions of times a second, pinning a full CPU core to 100% even while ch sits empty. It is almost never what you actually want. Two fixes cover nearly every real case: if you are polling on a schedule, use a time.Ticker and add its channel as a real select case (removing the default and the manual loop entirely); if you are waiting for the next value with no deadline, drop default altogether and let select block — that is what it is designed to do, and it costs nothing while idle.

A common pattern is combining select with time.After, which returns a channel that receives a value after the specified duration:

select {
case result := <-work:
fmt.Println("done:", result)
case <-time.After(2 * time.Second):
fmt.Println("timed out")
}

Prefer context.WithTimeout (covered in the Context lesson) when the deadline must propagate to callees; time.After is fine for a one-off local timeout — since Go 1.23 an unreferenced timer is garbage-collected even if you never call Stop.

The GC reclaiming an unstopped timer (since Go 1.23) fixes the old memory leak, but it does not make time.After free. Every call still allocates a brand-new time.Timer and a brand-new channel — fine for a one-off timeout, expensive when called on every iteration of a hot loop:

for {
select {
case v := <-ch:
process(v)
case <-time.After(time.Second): // allocates a new timer every single iteration
fmt.Println("no value this second")
}
}

Inside a loop, create the timer once with time.NewTimer and call Reset on it after each iteration instead — one allocation total instead of one per iteration:

t := time.NewTimer(time.Second)
defer t.Stop()
for {
select {
case v := <-ch:
process(v)
case <-t.C:
fmt.Println("no value this second")
}
if !t.Stop() {
<-t.C // drain a timer that fired between the select and the Stop call
}
t.Reset(time.Second)
}

Assigning nil to a channel variable that a select case reads from disables that case permanently — a nil channel never becomes ready, so select will never pick it again, no matter how many more times the loop runs. This is the standard way to stop select from repeatedly re-selecting a channel that has already given you everything it will ever give.

The clearest example is draining two channels until both are closed. A closed channel’s receive case is always immediately ready (with ok == false), so without disabling it, the loop would spin on the drained channel instead of waiting on the one still producing values:

for a != nil || b != nil {
select {
case v, ok := <-a:
if !ok {
a = nil // disable: this case can never fire again
continue
}
sum += v
case v, ok := <-b:
if !ok {
b = nil // disable: this case can never fire again
continue
}
sum += v
}
}

The loop condition a != nil || b != nil and the nil-out inside each !ok branch together guarantee the loop exits exactly when both channels are drained and closed — never spinning on a closed channel, never exiting early.

Any select inside a loop that might run for a while should include a case <-ctx.Done(), exactly like the pattern above disables a drained channel. Without it, a loop can keep consuming from a work channel long after the surrounding request has been cancelled — wasted work that nobody is waiting for:

for {
select {
case v, ok := <-work:
if !ok {
return
}
process(v)
case <-ctx.Done():
return ctx.Err() // stop promptly instead of draining everything first
}
}

The Context lesson covers ctx.Done() and ctx.Err() in full; the rule from a select point of view is simply: if the loop can outlive a single quick operation, give cancellation its own case.

The snippet below demonstrates all three select behaviours in sequence — deterministically by using separate selects with pre-loaded buffered channels, followed by a default branch demo.

package main
import "fmt"
func main() {
// Pre-load two buffered channels so both are ready
ch1 := make(chan string, 1)
ch2 := make(chan string, 1)
ch1 <- "one"
ch2 <- "two"
// Drain ch1 with its own select — guaranteed to receive "one"
select {
case msg := <-ch1:
fmt.Println("ch1:", msg)
}
// Drain ch2 with its own select — guaranteed to receive "two"
select {
case msg := <-ch2:
fmt.Println("ch2:", msg)
}
// default makes select non-blocking
ch3 := make(chan int)
select {
case v := <-ch3:
fmt.Println("received", v)
default:
fmt.Println("no value ready, default taken")
}
// Fan-in: select from whichever of two channels has a value
// (use separate selects for deterministic playground output)
r1 := make(chan int, 1)
r2 := make(chan int, 1)
r1 <- 10
r2 <- 20
select {
case v := <-r1:
fmt.Println("r1:", v)
}
select {
case v := <-r2:
fmt.Println("r2:", v)
}
fmt.Println("done")
}

The second runnable snippet below covers the two patterns just introduced: nil-ing out a drained channel so select stops re-selecting it, and giving a cancelled context its own case so a loop stops promptly instead of draining everything else first.

package main
import (
"context"
"fmt"
)
func main() {
// Nil-out to disable a case: once a channel is drained and closed, set
// the local variable to nil so its select case can never fire again --
// otherwise a closed channel's case is always immediately ready (with
// ok == false), spinning the loop on it forever.
a := make(chan int, 2)
b := make(chan int, 2)
a <- 1
a <- 2
close(a)
b <- 10
b <- 20
close(b)
sum := 0
for a != nil || b != nil {
select {
case v, ok := <-a:
if !ok {
a = nil // disable: this case never fires again
continue
}
sum += v
case v, ok := <-b:
if !ok {
b = nil // disable: this case never fires again
continue
}
sum += v
}
}
fmt.Println("sum:", sum) // deterministic regardless of interleaving order
// ctx.Done() inside a loop: exit promptly on cancellation instead of
// draining every remaining item first.
ctx, cancel := context.WithCancel(context.Background())
work := make(chan int) // deliberately never sent to
cancel() // cancel before the loop starts
processed := 0
loop:
for {
select {
case <-work:
processed++
case <-ctx.Done():
fmt.Println("stopped early:", ctx.Err())
break loop
}
}
fmt.Println("processed before stopping:", processed)
}
What you getBenefitCost
select over multiple channelshandle several event sources at oncerandom selection when multiple cases are ready
default casenon-blocking checka tight loop spins the CPU
select with context.Done()easy cancellationmust be added to every select that might run long
  • select behaves like switch — select picks at random when multiple cases are ready, not first-match
  • A default case makes select faster — a tight loop around a default case spins the CPU and burns it
  • time.After leaks because it spawns a new goroutine every call — a timer never spawns a goroutine; before Go 1.23 an unstopped timer sat on the heap until it fired (a real leak — of the timer object, not a goroutine); since 1.23 the GC reclaims it even without Stop, but calling time.After repeatedly in a hot loop still allocates a new timer object every iteration — use time.NewTimer + Reset instead

select with no cases at all — select {} — can never have a ready case, by construction, so it blocks the current goroutine forever. If that goroutine is main and there is no other goroutine left running, that is a real, whole-program deadlock, and the runtime reports it exactly like the nil-channel deadlock from the Channels lesson:

func main() {
select {}
}
fatal error: all goroutines are asleep - deadlock!
goroutine 1 [select (no cases)]:
main.main()
/tmp/selectempty/main.go:4 +0x1c
exit status 2

The [select (no cases)] tag names the exact cause, the same way [chan receive (nil chan)] did for a nil channel. select {} is occasionally used intentionally to keep main alive forever — a leaner alternative to for {} — but that only works when other goroutines are still doing useful work in the background (an HTTP server’s listener goroutine, for example). Used with nothing else running, it is indistinguishable from this fatal error.

When a generated snippet uses select inside a loop, check for these before trusting it:

  • Does a case inside a select that sits inside a for use a bare break, expecting it to exit the loop? It only exits the select — the for keeps going.
  • Is there a default case inside a loop with nothing else slowing it down? That is a busy-spin burning a full CPU core.
  • Does the loop call time.After(d) as a select case on every iteration, instead of creating one time.NewTimer before the loop and calling Reset after each iteration?
  • Is there a case <-ctx.Done() in every select that sits inside a loop that could run for a while — or can the loop keep consuming work after the caller has already given up?
  • Does a channel that should stop being selected (already drained and closed) get nil’d out, or will its always-ready ok == false case spin the loop forever?

🐛 Spot the bug

for {
select {
case <-ctx.Done():
fmt.Println("stopping")
break // intended to exit the for loop
case v := <-work:
process(v)
}
}
Show the answer

break inside a select case only breaks out of the select statement — select is one of the constructs (with switch) that a bare break targets before an enclosing for. Once ctx is already cancelled, ctx.Done()’s case is permanently ready, so the loop re-enters select, hits the same case, prints "stopping" again, and repeats — a busy-spin that never actually stops. Captured output from a version of this program with a safety counter added just to observe the behavior:

stopping
stopping
stopping
stopping

The fix is a labeled break naming the for loop explicitly: loop: for { select { case <-ctx.Done(): return /* or: break loop */ ... } }. Returning from the enclosing function (when that is valid) sidesteps the ambiguity entirely, which is why the runnable examples above use return or a labeled break loop.

What does Go do when multiple cases in a select are simultaneously ready?
What is the effect of adding a default case to a select?
What does time.After(d) return?
What keeps this loop from spinning forever on the closed, empty channel a?
a, b := make(chan int, 1), make(chan int, 1)
close(a)
for a != nil || b != nil {
    select {
    case _, ok := <-a:
        if !ok { a = nil }
    case v := <-b:
        fmt.Println(v)
    }
}
Assuming ctx is already cancelled, what happens?
for {
    select {
    case <-ctx.Done():
        fmt.Println("stopping")
        break
    case v := <-work:
        process(v)
    }
}
What happens when this runs, with no other goroutines started?
func main() {
    select {}
}