skip to content

When does Go evaluate the channel and send-value expressions of a `select` statement's cases?

level: middleimportance: nice to knowfreq 27%

answer

  1. select is not lazy like if/else
  2. it needs every channel before it can choose
  3. the sent value must exist to be offered
  4. side effects happen even in losing cases
  5. hoist the call, send a variable

basics

~20 s

Go evaluates every case's channel expression, and every send case's value, exactly once in source order on entering the select, before any case is chosen. A call in a case header therefore runs on every pass, even when its case does not win.

solid answer

~50 s

On entering a `select`, Go evaluates the channel operand of every receive case, and both the channel and the right-hand value of every send case, exactly once and in source order. Only then does it decide which operations can proceed and pick one. The practical consequence is that side effects in those expressions happen unconditionally: `case out <- next():` calls `next()` every time control reaches the select, and if a different case wins, the value `next()` produced is simply discarded. If `next()` pops from a queue or advances a counter, you have silently dropped an item. The same applies inside a for-select loop to anything expensive — a call that allocates in a case expression allocates on every iteration, not only when that case is chosen. Compute the value before the select and send a variable.

code

go · 8 lines
go
// nextFrame advances an animation counter every time it is called
select {
case frames <- nextFrame():
	// reached only when the send proceeds
case <-redraw:
	// nextFrame() already ran and its frame was discarded
	paint()
}

go deeper

for a junior

Remember the shape of the rule rather than the wording: everything in the case headers runs first, then one case is picked. Do not put calls that change state into a case header.

for a middle

Be able to state that evaluation is exactly once, in source order, on entering the statement, and to explain why a lazy alternative is impossible when the runtime must know every channel before choosing.

for a senior

Recognise the failure from its symptom — items disappearing or allocations with no visible caller — and fix it by hoisting the call so the produced value survives whichever case wins.

for a principal

Treat it as a review standard: case headers name a channel and an existing value, nothing else. That rule is cheap to enforce and removes a whole class of bug nobody finds by reading case bodies.

## The rule Go's specification is explicit about this. For all the cases in a select statement, the channel operands of receive operations, and the channel operands and right-hand-side expressions of send statements, are **evaluated exactly once, in source order, upon entering the select statement**. The result is a fixed set of channels to receive from and a fixed set of (channel, value) pairs to send. Only after that does the runtime determine which of those operations can proceed and choose one. So three things happen in order: evaluate every case's expressions, choose one eligible operation, run that one case's body. ## Why it surprises people The mental model most people carry over from `switch` or from `if/else if` is lazy: evaluate a condition, and if it fails, evaluate the next. Select is not lazy. It cannot be — to know which operations are possible it has to know all the channels first, and to offer a send it has to have the value in hand. The visible consequence is that expressions in cases that lose still ran: ```go select { case frames <- nextFrame(): // only reached if the send proceeds case <-redraw: paint() } ``` `nextFrame()` is called every time this select is entered. If the `redraw` case is the one chosen, the frame that `nextFrame()` produced is discarded — and if `nextFrame` advances an animation counter or pops from a buffer, that state change has already happened. The animation skips frames, and it skips them at a rate that depends on how often the other case wins, which makes it look like a timing bug rather than an evaluation-order bug. ## Inside a loop, it is also a cost A for-select loop enters the select on every iteration, so every case's expressions are re-evaluated on every iteration. Anything that allocates, locks, or does I/O in a case expression does so at loop frequency: ```go for { select { case out <- expensive(): // runs every pass case <-done: return } } ``` A benchmark with `-benchmem` on a loop like this shows allocations you cannot find in any case body, because they are not in a body — they are in the case header. ## The fix Hoist. Compute the value before the select and send the variable: ```go for { v, ok := nextFrame() if !ok { return } select { case frames <- v: case <-done: return } } ``` Now the frame is produced once, and if `done` wins, the value is still in `v` — you can decide explicitly whether to drop it, retry it, or hand it back. The general rule of thumb is that a case header should name a channel and, for a send, a value that already exists. Keep calls out of it. The exception is a call that has no side effect and is cheap enough not to matter, but a reviewer cannot tell that by looking, which is itself an argument for hoisting. ## Source order does matter — for exactly this It is tempting to say "source order is meaningless in a select", because it does not affect which ready case is chosen. That is half right. Order determines the sequence in which the case expressions are evaluated, so two case expressions with side effects that depend on each other will observe each other in written order. That is a fragile thing to rely on, but it is defined, and it is the one place order is significant. ## Receive cases too The same rule applies to the channel operand of a receive. `case <-c.Next():` calls `c.Next()` once per entry to the select, and whatever channel it returns is the one this pass waits on. That is why a case expression that manufactures a fresh channel every pass behaves differently from one that reuses a channel stored in a variable: each entry to the select is waiting on a different object, and anything the previous pass had queued on the old one is unreachable.

  • Does the body of a case that is not chosen run?
    No — exactly one case body runs. What runs unconditionally is the evaluation of every case's channel expression and every send case's value expression, which happens once each time control reaches the select. Bodies are chosen; headers are not.
  • How would you rewrite `case out <- next():` so nothing is lost when another case wins?
    Hoist it: call `next()` before the select, keep the result in a variable, and write `case out <- v:`. If a different case is chosen, `v` still holds the item and you decide explicitly whether to retry it next pass, drop it, or return it to its source.
  • What does this cost in a hot for-select loop?
    Every iteration re-evaluates every case header. A call that allocates in a case expression allocates once per loop pass regardless of which case wins, which shows up in a benchmark run with -benchmem as allocations attributable to no case body at all.

saying these in an interview costs you the question

  • Thinks the send value is computed only if that case wins
  • Assumes unchosen cases have no side effects
  • Believes select evaluates cases lazily until one succeeds
  • Says source order is entirely meaningless in a select
  • Puts a state-advancing call in a case header and calls it idiomatic