golangtutorialAll tutorials

Go select Statement: Multiplexing Channels Explained

Learn Go's select statement with tested code: the random-choice rule, non-blocking default, timeouts, the nil-channel trick, and context cancellation.

Concurrency · Lesson 4Saved in this browser. No account required.

select lets one goroutine wait on several channel operations at once and act on whichever is ready first. This tutorial covers the exact rules: the random choice when multiple cases are ready, the non-blocking default, timeouts without leaking timers, and cancellation with context. Every example was compiled and run on Go 1.24.7.

Works with Go 1.21+ (all output verified on Go 1.24.7; the one version-specific detail, timer garbage collection, is called out where it matters). You should already know how channels block. If that is shaky, read the channels tutorial first, because select is built entirely on channel send and receive semantics.

select waits on multiple channel operations at once

A select looks like a switch, but every case is a channel send or receive. It blocks until at least one case can proceed, runs that one case, and continues. It is the tool for the situation where a goroutine has more than one thing it might need to react to: a result from the database or the cache, a new job or a shutdown signal, data or a timeout.

Here is the canonical shape, racing two backends and taking whichever answers first:

package main

import (
	"fmt"
	"time"
)

func main() {
	dbResult := make(chan string)
	cacheResult := make(chan string)

	go func() {
		time.Sleep(60 * time.Millisecond)
		dbResult <- "profile from database"
	}()
	go func() {
		time.Sleep(20 * time.Millisecond)
		cacheResult <- "profile from cache"
	}()

	select {
	case res := <-dbResult:
		fmt.Println(res)
	case res := <-cacheResult:
		fmt.Println(res)
	}
}

Output:

profile from cache

The cache answers in 20ms, its case fires, and main never waits for the slower database. This is multiplexing: one goroutine, many possible inputs, react to the first. Without select you would need a receive per channel and no way to take them in arrival order.

The blocking rule, and why a ready case is chosen at random

Two rules govern which case runs, and they are the whole model:

  1. If no case is ready, select blocks until one is (unless there is a default, covered next).
  2. If more than one case is ready at the same time, select picks one with a uniform pseudo-random selection. Not the first in source order. Random.

The spec is explicit: “If one or more of the communications can proceed, a single one that can proceed is chosen via a uniform pseudo-random selection.” This is a deliberate fairness guarantee. If select always took the first ready case, a channel listed early that is constantly ready would starve every case below it. Randomness means a busy channel cannot monopolize the loop.

You can measure it. A receive on a closed channel is always ready and returns immediately, so closing two channels makes both cases ready on every single iteration. Over 100,000 rounds the split should be even:

package main

import "fmt"

func main() {
	// A receive on a closed channel is always ready, immediately.
	// So both cases are ready on every iteration.
	left := make(chan struct{})
	right := make(chan struct{})
	close(left)
	close(right)

	lefts, rights := 0, 0
	for i := 0; i < 100000; i++ {
		select {
		case <-left:
			lefts++
		case <-right:
			rights++
		}
	}
	fmt.Printf("left: %d  right: %d\n", lefts, rights)
}

Output (varies per run, always near 50/50):

left: 50014  right: 49986

Three runs on this machine gave 49967/50033, 50014/49986, and 50101/49899. If your code assumes the first-listed case wins when both are ready, it is wrong roughly half the time. That assumption is the most common select bug, and it is in the mistakes section below.

default makes a select non-blocking

Add a default case and select stops blocking entirely: if no channel case is ready at the instant it runs, default executes. This is how you do a non-blocking receive, a “check if anything is waiting, otherwise move on” poll:

package main

import "fmt"

func main() {
	events := make(chan string, 4)

	// nothing has been sent yet
	select {
	case ev := <-events:
		fmt.Println("got event:", ev)
	default:
		fmt.Println("no data yet, moving on")
	}

	events <- "cache.evicted"

	select {
	case ev := <-events:
		fmt.Println("got event:", ev)
	default:
		fmt.Println("no data yet, moving on")
	}
}

Output:

no data yet, moving on
got event: cache.evicted

The same shape works for a non-blocking send: case metrics <- m: paired with a default: that drops the value turns “block the request path until the metrics channel has room” into “shed the metric under load.” For telemetry that is usually the trade you want. Be careful though: default in a loop with nothing else ready spins at full CPU, which is the busy-loop mistake later.

time.After gives you a timeout, with a catch

time.After(d) returns a channel that delivers one value after duration d. Race it against your real work and you have a timeout:

package main

import (
	"fmt"
	"time"
)

func main() {
	paymentDone := make(chan string)

	go func() {
		time.Sleep(300 * time.Millisecond) // provider is slow today
		paymentDone <- "charge captured"
	}()

	select {
	case status := <-paymentDone:
		fmt.Println(status)
	case <-time.After(100 * time.Millisecond):
		fmt.Println("timeout: provider did not answer in 100ms")
	}
}

Output:

timeout: provider did not answer in 100ms

The catch: time.After allocates a fresh timer on every call, and that timer runs until it fires even if its case is never selected. In a one-shot select that is harmless. In a hot loop it is not. Put time.After inside a for loop that iterates thousands of times a second and, on Go versions before 1.23, every un-fired timer stayed alive in memory until its full duration elapsed: a real, measurable leak of live timers. Go 1.23 changed timers so an unreferenced one is garbage collected promptly even before it fires, which removes the memory pile-up, but time.After still allocates a timer object per iteration for nothing.

The fix in any loop is one reusable time.Timer you Reset each pass:

package main

import (
	"fmt"
	"time"
)

// One timer, reset each iteration. No per-iteration allocation and nothing
// left running after the loop exits.
func main() {
	jobs := make(chan int)
	go func() {
		for i := 1; i <= 3; i++ {
			time.Sleep(20 * time.Millisecond)
			jobs <- i
		}
		close(jobs)
	}()

	idle := time.NewTimer(100 * time.Millisecond)
	defer idle.Stop()

	for {
		idle.Reset(100 * time.Millisecond)
		select {
		case job, ok := <-jobs:
			if !ok {
				fmt.Println("jobs closed, stopping")
				return
			}
			fmt.Println("handled job", job)
		case <-idle.C:
			fmt.Println("idle 100ms, flushing")
		}
	}
}

Output:

handled job 1
handled job 2
handled job 3
jobs closed, stopping

In a real service you rarely reach for either one directly, because the timeout usually belongs to the whole request, not this one select. That is what context is for.

select plus ctx.Done() is the cancellation idiom

The idiomatic way to make any select cancellable is a case <-ctx.Done():. context.Context exposes a Done() channel that closes when the context is cancelled or its deadline passes, and a closed channel is always ready, so that case fires the moment cancellation happens. This is how goroutines are told to stop across every real Go codebase.

Here a poller ticks every 50ms and a collector counts results, both bounded by a 175ms deadline:

package main

import (
	"context"
	"fmt"
	"time"
)

// pollHealth checks an endpoint every 50ms until the context is cancelled.
func pollHealth(ctx context.Context, checks chan<- string) {
	ticker := time.NewTicker(50 * time.Millisecond)
	defer ticker.Stop()

	for {
		select {
		case <-ctx.Done():
			fmt.Println("poller stopping:", ctx.Err())
			return
		case <-ticker.C:
			checks <- "ok"
		}
	}
}

func main() {
	ctx, cancel := context.WithTimeout(context.Background(), 175*time.Millisecond)
	defer cancel()

	checks := make(chan string)
	go pollHealth(ctx, checks)

	count := 0
	for {
		select {
		case <-ctx.Done():
			fmt.Println("collected", count, "health checks before shutdown")
			return
		case <-checks:
			count++
		}
	}
}

Output (line order between the two goroutines can vary):

poller stopping: context deadline exceeded
collected 3 health checks before shutdown

Ticks land at 50, 100, and 150ms, so three checks arrive before the 175ms deadline closes Done() for both goroutines at once. Passing the same ctx to every goroutine gives you one switch that shuts the whole tree down. The full treatment of deadlines, cancellation propagation, and ctx.Err() is in the context guide; for select, all you need is that ctx.Done() is just another receive case.

Setting a channel to nil disables its case

A receive or send on a nil channel blocks forever. Standalone that is a bug (a var ch chan int someone forgot to make). Inside a select it is a feature: a case whose channel is nil can never be ready, so select skips it. Assigning nil to a channel variable removes its case at runtime.

The standard use is draining multiple sources and retiring each as it closes. Without the nil trick, a closed channel stays permanently ready and returns zero values forever, spinning the loop:

package main

import "fmt"

// drainBoth reads from two feeds until both are closed. When a feed closes,
// setting its variable to nil disables that case, so select never picks it
// again and the loop can wait on whatever is left.
func drainBoth(metrics, logs <-chan string) {
	for metrics != nil || logs != nil {
		select {
		case v, ok := <-metrics:
			if !ok {
				metrics = nil // retire this case
				continue
			}
			fmt.Println("metric:", v)
		case v, ok := <-logs:
			if !ok {
				logs = nil // retire this case
				continue
			}
			fmt.Println("log:", v)
		}
	}
	fmt.Println("both feeds drained")
}

func main() {
	metrics := make(chan string)
	logs := make(chan string)

	go func() {
		metrics <- "cpu=0.7"
		metrics <- "cpu=0.9"
		close(metrics)
	}()
	go func() {
		logs <- "request handled"
		close(logs)
	}()

	drainBoth(metrics, logs)
}

Output (interleaving varies, “both feeds drained” is always last):

log: request handled
metric: cpu=0.7
metric: cpu=0.9
both feeds drained

The loop condition metrics != nil || logs != nil reads directly: keep going while at least one feed is live. This nil-to-disable pattern shows up throughout the standard library and any serious concurrent Go.

Empty select{} blocks forever, sometimes on purpose

select {} with no cases can never have a ready case, so it blocks the goroutine forever. Run it as your whole program and the runtime detects that every goroutine is stuck:

package main

func main() {
	select {} // no cases: blocks forever
}

Output:

fatal error: all goroutines are asleep - deadlock!

goroutine 1 [select (no cases)]:
main.main()

That looks like a bug, and usually is. But it is occasionally intentional: a program whose real work runs entirely in background goroutines (an HTTP server started in one, a set of workers in others) sometimes parks main on select {} so the process stays alive without burning CPU. It is a deliberate “block here forever, the useful work is elsewhere” marker. Use it knowingly, not by accident.

A real event loop: jobs, a ticker, and shutdown in one select

The place select earns its keep is the long-running worker that has to juggle several concerns at once. Build it up in two steps.

Start with the minimum: process jobs until told to stop. Two cases, one for work and one for shutdown:

package main

import (
	"context"
	"fmt"
)

// runWorker v1: handle jobs until the context is cancelled.
func runWorker(ctx context.Context, jobs <-chan int) {
	for {
		select {
		case job := <-jobs:
			fmt.Println("processed job", job)
		case <-ctx.Done():
			fmt.Println("shutdown requested, worker exiting")
			return
		}
	}
}

func main() {
	ctx, cancel := context.WithCancel(context.Background())
	jobs := make(chan int)

	go func() {
		for i := 1; i <= 3; i++ {
			jobs <- i
		}
		cancel() // done producing, tell the worker to stop
	}()

	runWorker(ctx, jobs)
}

Output:

processed job 1
processed job 2
processed job 3
shutdown requested, worker exiting

Now the version you would actually ship: a batching writer that buffers jobs, flushes them on a periodic ticker, and flushes one last time on shutdown so nothing buffered is lost. Three concerns, one select. Note the jobs = nil when the input closes: it retires the input case so the loop keeps serving the ticker and waits for the context instead of spinning on a closed channel.

package main

import (
	"context"
	"fmt"
	"time"
)

// batchWriter buffers jobs and flushes them every interval, plus once more on
// shutdown so nothing buffered is lost. One select drives all three concerns:
// incoming work, the periodic timer, and cancellation.
func batchWriter(ctx context.Context, jobs <-chan int) {
	ticker := time.NewTicker(40 * time.Millisecond)
	defer ticker.Stop()

	var batch []int
	flush := func(reason string) {
		if len(batch) == 0 {
			return
		}
		fmt.Printf("flush (%s): %v\n", reason, batch)
		batch = batch[:0]
	}

	for {
		select {
		case job, ok := <-jobs:
			if !ok {
				jobs = nil // stop selecting a closed channel; wait for ctx
				continue
			}
			batch = append(batch, job)
		case <-ticker.C:
			flush("timer")
		case <-ctx.Done():
			flush("shutdown") // drain what is buffered before leaving
			fmt.Println("writer exited cleanly")
			return
		}
	}
}

func main() {
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	jobs := make(chan int)
	go func() {
		for i := 1; i <= 7; i++ {
			jobs <- i
			time.Sleep(15 * time.Millisecond)
		}
	}()

	go batchWriter(ctx, jobs)

	time.Sleep(100 * time.Millisecond)
	cancel() // signal shutdown while a partial batch is still buffered
	time.Sleep(20 * time.Millisecond)
}

Output (exact batch boundaries shift a little with scheduling; the shape holds):

flush (timer): [1 2 3]
flush (timer): [4 5 6]
flush (shutdown): [7]
writer exited cleanly

The last two lines are the point. When cancellation arrives mid-batch, the ctx.Done() case flushes the partial batch before returning, so no buffered job is dropped on shutdown. Remove that flush and job 7 vanishes silently on every restart. This is the anatomy of most Go background workers: a ticker for periodic work, one or more data channels, and a Done() case that both stops the loop and does final cleanup.

Common mistakes

Assuming select picks the first case. It does not. When multiple cases are ready it chooses uniformly at random, as the 50/50 run above showed. Code like a select that lists a priority channel first and a normal channel second does not give the priority channel priority. If you genuinely need priority, nest selects: try the high-priority channel alone with a default, and only fall through to a full select if it was empty.

Busy-looping with default in a hot loop. A default case means “never block,” so a for loop wrapping a select with a default and no ready channel spins as fast as the CPU allows:

for {
	select {
	case job, ok := <-work:
		if !ok {
			return
		}
		handle(job)
	default:
		// nothing to do, so we spin at full CPU
	}
}

Measured with a spin counter, that loop burned roughly 37 million iterations in the 200ms before work arrived, one core pinned at 100% doing nothing. The fix is almost always to drop the default and let select block until a channel is ready, which costs zero CPU while parked. Use default for a genuine one-shot poll, never as the body of a tight loop.

Using time.After inside a tight loop. Covered above: a new timer every iteration, pure waste, and on Go before 1.23 a growing pile of live timers. Reuse one time.Timer with Reset, or better, put the deadline on a context and select ctx.Done().

Forgetting a select can block forever. A select with no default blocks until some case is ready. If none ever becomes ready, the goroutine parks permanently. When it is the only goroutine you get the all goroutines are asleep fatal error; when other goroutines keep running, the stuck one leaks silently and you find it later in a goroutine profile. Any select that waits on external input should also have a ctx.Done() or a timeout case so it can always make progress or give up.

What next

You can now read any select: predict which case runs, make it non-blocking, add timeouts without leaking timers, and shut a worker down cleanly. Build on it:

If the channel mechanics under these examples felt uncertain, step back to the complete Go tutorial and work forward. select rewards a solid grasp of how channels block.