select

select waits on several channel operations at once and runs whichever one becomes ready first.

Syntaxselect { case v := <-in: ...; case out <- v: ...; case <-time.After(d): ...; default: ... }

A plain receive handles one channel. select handles several. It lists channel operations as cases, waits until one of them can proceed, and runs that one.

Every case is a send or a receive, not a condition. case msg := <-a: runs when a value can be received from a. case b <- v: runs when a value can be sent on b. If more than one case is ready, Go picks one at random, so you cannot rely on any order between cases.

default and time.After

A default case changes the waiting entirely. With one present, select never blocks: if no channel operation can proceed at that instant, default runs instead. That is how you write a send that drops its value rather than waiting for room.

For a deadline, add a case on a channel that becomes ready after a delay. time.After(d) returns a <-chan Time which receives one value once d has passed. Whichever case is ready first wins, so a slow channel loses to the timer.

What trips people up

A select with a default inside a loop spins a CPU core flat out, because it never waits for anything. Either drop the default or give the loop something real to block on.

A closed channel is always ready to receive, so a case on one is chosen over and over. Set that variable to nil once the channel is drained: a nil channel is never ready, which takes the case out of the running.

Since Go 1.23 the timer behind time.After can be collected before it fires, so using it in a loop no longer holds memory until the delay elapses.

Example

Example · go
package main

import (
	"fmt"
	"time"
)

func main() {
	fast := make(chan string, 1)
	slow := make(chan string)

	fast <- "fast result" // buffered, so this send does not block

	// select waits until one of its cases can run, then runs that one.
	select {
	case msg := <-fast:
		fmt.Println("got:", msg)
	case msg := <-slow:
		fmt.Println("got:", msg)
	case <-time.After(2 * time.Second):
		// time.After returns a channel that gets a value after the wait.
		fmt.Println("timed out")
	}

	// A default case makes select non-blocking. If nothing is ready at
	// this instant, default runs instead of waiting.
	select {
	case msg := <-slow:
		fmt.Println("got:", msg)
	default:
		fmt.Println("nothing ready")
	}
}

// Output:
// got: fast result
// nothing ready

When to use it

  • Give a slow upstream call a two-second deadline and return an error instead of hanging.
  • Drop a metrics sample when its buffer is full rather than slowing the request.
  • Read from a results channel and a shutdown channel in the same loop.
  • Merge two event streams into one handler goroutine.

More examples

Reading two sources in one loop

The basic shape: a loop with a <code>select</code> in it, pulling from whichever source has something ready. The counts are fixed but the interleaving is not, which is the single most important thing to internalise about <code>select</code>. Any test that asserts the order values arrive in from two independent senders will pass locally and fail on a busy machine. Use this when one goroutine must service several streams, for example results from workers and a shutdown signal.

Example · go
package main

import "fmt"

func main() {
	letters := make(chan string)
	numbers := make(chan string)

	go func() {
		letters <- "a"
		letters <- "b"
	}()
	go func() {
		numbers <- "1"
		numbers <- "2"
	}()

	// Four values are coming, two from each sender. select takes whichever
	// is ready, so the order the two sources interleave in is not fixed.
	// The counts are, which is why this prints the same thing every run.
	fromLetters, fromNumbers := 0, 0
	for i := 0; i < 4; i++ {
		select {
		case <-letters:
			fromLetters++
		case <-numbers:
			fromNumbers++
		}
	}
	fmt.Println("letters:", fromLetters, "numbers:", fromNumbers)
}

// Output:
// letters: 2 numbers: 2

Dropping on a full buffer, and giving up after a delay

Two ways to refuse to wait, side by side. <code>default</code> gives up immediately, which is right for data you would rather lose than let slow down the request that produced it: metrics samples, debug log lines, cache warm-ups. <code>time.After</code> gives up after a delay, which is right when the value matters enough to wait a little. Note that both cases here are sends, not receives. A <code>select</code> case can be either, and a send case with a <code>default</code> is the standard non-blocking send.

Example · go
package main

import (
	"fmt"
	"time"
)

func main() {
	// A log sink nobody is draining. Capacity 1, so it fills at once.
	logs := make(chan string, 1)

	for i := 0; i < 3; i++ {
		msg := fmt.Sprintf("event %d", i)
		select {
		case logs <- msg:
			fmt.Println("queued", msg)
		default:
			// The buffer is full. Dropping a log line beats blocking the
			// request that produced it.
			fmt.Println("dropped", msg)
		}
	}

	// A deadline instead of an immediate drop. Nothing receives, so the
	// timer case wins after 50 milliseconds.
	select {
	case logs <- "final":
		fmt.Println("queued final")
	case <-time.After(50 * time.Millisecond):
		fmt.Println("gave up on final")
	}
}

// Output:
// queued event 0
// dropped event 1
// dropped event 2
// gave up on final

Disabling a case by setting the channel to nil

The fix for the most common <code>select</code> bug. Merging two streams and stopping when both end looks simple, but a closed channel is permanently ready, so the loop would receive zero values from it as fast as the CPU allows. Assigning <code>nil</code> to the variable removes that case, because a receive from a nil channel can never proceed. The loop condition then ends the loop once both are nil. Closing and the two-result receive used here are covered in Closing Channels and range.

Example · go
package main

import "fmt"

func main() {
	a := make(chan string)
	b := make(chan string)

	go func() {
		a <- "from a"
		close(a)
	}()
	go func() {
		b <- "from b"
		close(b)
	}()

	// A closed channel is always ready to receive, so leaving it in the
	// select would spin. Receiving from a nil channel never proceeds, so
	// setting a drained channel to nil takes its case out of the running.
	received := 0
	for a != nil || b != nil {
		select {
		case _, ok := <-a:
			if ok {
				received++
			} else {
				a = nil
			}
		case _, ok := <-b:
			if ok {
				received++
			} else {
				b = nil
			}
		}
	}
	fmt.Println("values received:", received)
}

// Output:
// values received: 2

Discussion

  • Be the first to comment on this lesson.