select Statements

A Go program often has several goroutines talking to each other over several channels at once, and you frequently need to react to whichever one is ready first rather than blocking on a single channel operation. The select statement is Go’s tool for exactly that: it waits on multiple channel operations simultaneously and runs the case that becomes ready. It is the mechanism behind timeouts, cancellation, non-blocking channel checks, and fan-in patterns that combine results from many goroutines into one.

Overview: How select Works

Syntactically, select looks like a switch statement, but it behaves very differently. Where switch evaluates a single expression and matches it against cases in order, select evaluates every channel operation listed in its cases — sends and receives — and blocks until at least one of them can proceed. If several are ready at the same instant, Go picks one of them uniformly at random, not the first one written in source order. This is a deliberate design choice: it prevents programs from accidentally depending on case ordering and helps avoid starvation, where one channel is always serviced before another simply because it appears first.

If none of the cases are ready and the select has a default case, the default runs immediately and the select does not block at all — this is how you build a non-blocking channel check. If there is no default and nothing is ready, the goroutine running the select simply parks: the Go runtime scheduler puts it to sleep and takes the OS thread it was using to run other runnable goroutines, waking it back up only when one of the channel operations becomes possible. This is the same cooperative, cheap blocking behavior that makes plain channel receives efficient — a blocked select does not spin or poll, it is woken by the runtime the moment a matching send or receive occurs on one of its channels.

One special case worth knowing: an empty select {} with no cases and no default blocks forever, because there is nothing that could ever become ready. This is occasionally used deliberately to park a goroutine (or the main goroutine) permanently, but far more often it is a bug — a sign that a case was accidentally left out.

Because select treats sends and receives uniformly, you can mix them in the same statement: one case might receive from a results channel while another sends a value on a work channel, and whichever operation becomes possible first wins. Combined with time.After, which returns a channel that receives a value once a duration has elapsed, select is also the standard way to implement timeouts in Go without any special language support for them.

Syntax

select {
case v := <-ch1:
	// use v, received from ch1
case ch2 <- x:
	// x was sent on ch2
case v, ok := <-ch3:
	// ok is false if ch3 is closed
default:
	// runs immediately if no other case is ready
}
Part Meaning
case v := <-ch: Proceeds when a value can be received from ch; v holds the received value.
case v, ok := <-ch: Same, but also reports whether the channel is open (ok == true) or closed (ok == false).
case ch <- x: Proceeds when x can be sent on ch without blocking.
default: Runs immediately if no other case is ready yet; makes the select non-blocking.

Examples

Example 1: Waiting on two channels

package main

import (
	"fmt"
	"time"
)

func main() {
	ch1 := make(chan string)
	ch2 := make(chan string)

	go func() {
		time.Sleep(50 * time.Millisecond)
		ch1 <- "first"
	}()
	go func() {
		time.Sleep(100 * time.Millisecond)
		ch2 <- "second"
	}()

	for i := 0; i < 2; i++ {
		select {
		case msg1 := <-ch1:
			fmt.Println("received:", msg1)
		case msg2 := <-ch2:
			fmt.Println("received:", msg2)
		}
	}
}

Output:

received: first
received: second

Two goroutines each sleep for a different duration and then send on their own channel. The first select call blocks until something is ready; only ch1‘s goroutine has woken up at the 50ms mark, so that case runs. The loop then runs a second select, which blocks until ch2 becomes ready around 100ms. Because the two sends happen at different times here, only one case is ever ready at once, so the result is deterministic — but if both channels were ready simultaneously, Go would pick between them at random.

Example 2: Non-blocking check with default

package main

import "fmt"

func main() {
	ch := make(chan int)

	select {
	case v := <-ch:
		fmt.Println("received:", v)
	default:
		fmt.Println("no value ready")
	}
}

Output:

no value ready

ch is unbuffered and nobody is sending on it, so the receive case can never proceed immediately. Because a default case is present, select does not block waiting for a sender — it falls through to default at once. This pattern is how you “peek” at a channel: try to receive, and if nothing is there right now, move on and do other work.

Example 3: Implementing a timeout

package main

import (
	"fmt"
	"time"
)

func fetchResult() <-chan string {
	resultCh := make(chan string)
	go func() {
		time.Sleep(200 * time.Millisecond)
		resultCh <- "data from server"
	}()
	return resultCh
}

func main() {
	resultCh := fetchResult()

	select {
	case res := <-resultCh:
		fmt.Println("success:", res)
	case <-time.After(100 * time.Millisecond):
		fmt.Println("timed out waiting for result")
	}
}

Output:

timed out waiting for result

fetchResult simulates slow work that takes 200ms. time.After returns a channel that receives a single value after the given duration — here, 100ms. Because the timeout channel becomes ready before resultCh does, the timeout case wins the race and the “real” result is discarded when it eventually arrives. This is the standard idiom for bounding how long you wait on any channel operation in Go.

How It Works Step by Step

Walking through what the runtime actually does when it hits a select:

  • It evaluates every channel expression and every value to be sent, in the order they are written, exactly once — even for cases that end up not being chosen.
  • It checks which cases can proceed without blocking right now (a receive with a value already waiting, a send with room in a buffer or a waiting receiver).
  • If exactly one case is ready, that case runs.
  • If multiple cases are ready simultaneously, one is chosen pseudo-randomly, so no channel is systematically favored over another.
  • If no case is ready and there is a default, the default runs and the select returns immediately without blocking.
  • If no case is ready and there is no default, the goroutine is parked by the scheduler (using no CPU) until a send or receive on one of the listed channels becomes possible, at which point the runtime wakes it and re-evaluates readiness.

In Example 3, both resultCh and the channel from time.After are evaluated up front, then the goroutine blocks until one of them has something to deliver — here, the timer channel wins the race at 100ms.

Common Mistakes

Mistake 1: Ignoring whether the channel is closed

Receiving from a closed channel never blocks — it returns the zero value immediately, over and over. If a select case only captures the value and not the second ok result, a loop around it can spin through zero values without any indication the channel is done.

package main

import "fmt"

func main() {
	ch := make(chan int)
	close(ch)

	for i := 0; i < 3; i++ {
		select {
		case v := <-ch:
			fmt.Println("got:", v)
		}
	}
}

Output:

got: 0
got: 0
got: 0

Every iteration reports “got: 0” as if real data arrived, but the channel is closed and empty — there is no way to tell from this code alone. Always capture the second value and check it when a channel might be closed:

package main

import "fmt"

func main() {
	ch := make(chan int)
	close(ch)

	for i := 0; i < 3; i++ {
		select {
		case v, ok := <-ch:
			if !ok {
				fmt.Println("channel closed, nothing more to receive")
				return
			}
			fmt.Println("got:", v)
		}
	}
}

Output:

channel closed, nothing more to receive

Mistake 2: Expecting break to exit the enclosing loop

A bare break inside a select case only exits the select statement itself — not a for loop that wraps it. This surprises people coming from languages where break always exits the nearest loop.

package main

import "fmt"

func main() {
	ch := make(chan int)
	close(ch)

	for i := 0; i < 3; i++ {
		select {
		case v, ok := <-ch:
			if !ok {
				fmt.Println("channel closed, stopping")
				break
			}
			fmt.Println("got:", v)
		}
		fmt.Println("loop iteration", i, "finished")
	}
}

Output:

channel closed, stopping
loop iteration 0 finished
channel closed, stopping
loop iteration 1 finished
channel closed, stopping
loop iteration 2 finished

The break only ends the select, so the surrounding for loop keeps running all three iterations. To actually exit the loop, label it and use a labeled break:

package main

import "fmt"

func main() {
	ch := make(chan int)
	close(ch)

loop:
	for i := 0; i < 3; i++ {
		select {
		case v, ok := <-ch:
			if !ok {
				fmt.Println("channel closed, stopping")
				break loop
			}
			fmt.Println("got:", v)
		}
	}
	fmt.Println("done")
}

Output:

channel closed, stopping
done

Best Practices

  • Always check the ok value in a case v, ok := <-ch: when the channel might be closed, so you can react to closure instead of looping on zero values.
  • Prefer context.Context and a case <-ctx.Done(): for cancellation over ad-hoc “done” channels — it composes cleanly with timeouts, deadlines, and cancellation propagated from callers.
  • Avoid calling time.After repeatedly inside a loop or a select that runs many times; its timer is not released until it fires, which can leak memory. Use time.NewTimer (and call Stop()) or time.NewTicker when you need repeated timing.
  • Do not treat case order in a select as priority — if two cases are ready together, the choice is random. If you truly need priority, check the higher-priority channel first in its own non-blocking select before falling into the general one.
  • Use a default case sparingly and never in a tight loop without some form of pacing (a sleep, a ticker) — a select with default in a hot loop busy-polls and burns CPU.
  • Remember that an empty select {} blocks forever; only use it when you deliberately want a goroutine to park permanently.

Practice Exercises

  • Write a program with two goroutines that each sleep for a different random-ish duration (use two different fixed durations) and then send a message on their own channel. Use a select in a loop to print both messages in the order they actually arrive.
  • Write a function that returns a channel of int and, in a separate goroutine, sends a value on it after a delay. In main, use select with time.After to print "timeout" if the value does not arrive within a shorter deadline than the delay, and the received value otherwise.
  • Create a buffered channel of capacity 1. In a loop that runs five times, use select with a default case to try sending an incrementing counter into the channel; print "sent" when the send succeeds and "channel full, skipping" when it doesn’t. (Hint: only one send will ever succeed, since nothing is draining the channel.)

Summary

  • select waits on multiple channel send/receive operations and proceeds with whichever one becomes ready.
  • If several cases are ready at once, Go picks one pseudo-randomly — case order is not priority.
  • A default case makes select non-blocking: it runs instantly if nothing else is ready.
  • With no default and nothing ready, the goroutine blocks efficiently until the runtime wakes it.
  • time.After combined with select is the idiomatic way to add a timeout to any channel operation.
  • Always capture and check the ok value when receiving from a channel that might be closed, inside or outside a select.
  • break inside a select case only exits the select, not an enclosing loop — use a labeled break when you need to exit the loop.
  • An empty select {} blocks forever; use it deliberately or not at all.