A select lets one goroutine wait on several channel operations at once and act on whichever one becomes ready first. It's the multi-way switch of Go's concurrency model. Once you've moved past a single sender and single receiver, almost every interesting channel pattern is built on select.
A select block lists a set of case clauses, each one a channel send or receive. The runtime evaluates all of them, picks one that's ready to proceed, and runs that case's body. The other cases are discarded for this iteration.
The syntax looks like a switch, but the cases are communications, not value comparisons.
Both <-orders and <-refunds are receive operations the select is watching. Only orders has a value, so that case fires. If both had been ready, the runtime would have picked one at random (more on that below). If neither had been ready, the select would have blocked until one became ready.
A select runs exactly one case per execution. To handle a sequence of channel events you usually wrap select in a for loop, which is the pattern most production code uses.
The semantics matter more than they appear. When a select runs, the runtime evaluates every case and classifies it as ready or not.
case v := <-ch is ready if ch has a value waiting or has been closed.case ch <- v is ready if a receiver is waiting (unbuffered) or there's room in the buffer (buffered).What happens next depends on how many cases are ready:
| Ready Cases | Behavior |
|---|---|
| Exactly one | That case runs. |
| Two or more | The runtime picks one uniformly at random and runs it. |
Zero, and no default | The select blocks until one becomes ready. |
Zero, with default | The default case runs immediately. |
The randomness in the multi-ready case is deliberate, not an implementation detail. It prevents starvation: if you have two channels both producing fast, a deterministic order would let one starve the other indefinitely. The Go spec guarantees this random choice.
The exact split varies run to run, but it's always close to 50/50. Don't write code that relies on a specific case firing first when multiple are ready. The runtime can and does pick either.
A default clause turns a blocking select into a non-blocking one. If no case is ready when the select runs, default fires immediately and the body of the select returns.
This is the building block for two common patterns: non-blocking sends and non-blocking receives.
Without the default, the third send would block forever because the buffer is full and nobody is receiving. With default, the program checks if the send can proceed instantly and falls through if not. This is exactly how you implement bounded queues, rate limiters, and "drop if busy" behavior.
The same shape works for receives. Non-blocking receive looks like this:
The receive isn't ready (no value, channel not closed), so default runs. This is useful for polling, but the next section explains why polling in a tight loop is a trap.
Cost: default removes blocking, not work. If you wrap a non-blocking receive in a tight for loop with no sleep, you'll spin the CPU at 100% checking an empty channel millions of times per second. Use a blocking select or add a sleep/timeout case instead.
select {} is a select with zero cases. It has no path forward, so it blocks the goroutine that ran it for the rest of the program's life.
The main goroutine parks at select {} and never returns. The background goroutine keeps printing. If main returned, the program would exit and the goroutine would die mid-loop.
This trick shows up in long-running servers and daemons that finish setup in main, kick off background workers, and have nothing else to do. Production code more often uses something like <-ctx.Done() or <-signalChan so the program can shut down cleanly, but select {} is the simplest way to express "block forever".
The canonical use of select is putting a timeout on a channel operation. time.After(d) returns a <-chan Time that delivers exactly one value after duration d. Wrap a receive in a select with time.After as the second case, and you've got a receive-with-timeout.
The first select finishes in 100ms because the price arrives well before the 1-second timer fires. The second one waits the full second, then the time.After case wins and the program reports a timeout.
A subtle but important detail: in the timeout case, the slow goroutine is still running. It will eventually try to send on the result channel, but since the buffer has capacity 1 and nobody else is reading, the send completes and the goroutine exits. If result had been unbuffered, the send would have blocked forever and leaked the goroutine. This is why "return-by-channel" helpers like this one usually use a buffered channel of size 1, sometimes called the "trash can" buffer.
The decision node is the select itself. Each branch is one possible outcome. The default branch only exists if you wrote one; without it, the select blocks until one of the other cases fires. The <-ctx.Done() branch is a placeholder for cancellation.
A single select handles one event. To handle a stream of events, wrap it in a for loop. This is the workhorse pattern of Go's concurrency: a worker goroutine sits in a for loop with a select inside, reacting to whatever comes in next.
The dispatcher's loop keeps running until either the orders channel is closed or stop fires. The , ok form on the receive distinguishes a real value from a closed channel.
This shape is different from a plain for v := range ch loop. range only watches one channel. for { select { } } lets a single goroutine react to multiple channels, including a cancellation signal, a heartbeat tick, a result channel, and an input stream all at once. When you need only one channel and don't need cancellation, range is simpler. When you need anything more, for plus select is the answer.
A common variant adds a periodic tick:
The heartbeat case fires roughly every 200ms regardless of incoming orders. Even if orders is busy, the ticker won't be starved because of the uniform random choice when multiple cases are ready. Note the defer tick.Stop(): tickers hold a resource and need explicit cleanup. Forgetting to stop a ticker is a classic leak.
The select statement is simple to write and easy to misuse. Three bugs come up over and over.
The non-blocking receive pattern is useful in isolation, but wrapping it in a tight loop without a sleep is a textbook way to peg a CPU core at 100%.
What's wrong with this code?
If orders has no value, the default case fires immediately. The loop runs the next iteration, the default fires again, and so on. The goroutine burns CPU spinning over an empty channel. On a multi-core machine you might not notice it eating 100% of one core, but it's wasting energy and stealing cycles from useful work.
Fix: Remove the default so the select blocks until an order arrives, or add a timer case so the loop wakes up periodically instead of constantly.
The first version (no default) is the right answer almost always. If you find yourself using default inside a for loop, ask whether the work really needs polling, or whether you can just block on the channel.
time.After(d) is a convenient one-liner, but each call creates a new *time.Timer internally. That timer holds memory and a runtime entry until it fires or is garbage collected. In a hot loop with a short interval, this adds up fast.
What's wrong with this code?
Every iteration of the loop, time.After(1 * time.Second) creates a brand new timer. If messages is busy and the receive case fires quickly, the timer never fires and just sits around until the runtime collects it a second later. With thousands of iterations per second, you can pile up thousands of pending timers, each consuming memory and scheduler attention. Profilers will show the timer heap growing.
Fix: Use time.NewTimer (one-shot) or time.NewTicker (recurring) and reuse it across iterations.
One timer is reused across the whole loop. The Stop / drain / Reset dance handles the case where the timer might have already fired but its value hasn't been received yet. For most workloads time.After is fine and the convenience is worth it. Switch to the reusable pattern only when you're actually in a hot loop or you've seen the timer pile-up in a profile.
For periodic events, time.NewTicker is even cleaner because it fires repeatedly without needing manual reset:
The ticker channel delivers a value every second, no matter what. Stopping it with defer ticker.Stop() releases its runtime resources when the function returns. Forgetting Stop() leaks the ticker.
Cost: time.After(d) allocates a fresh timer on each call. In a select that fires thousands of times per second, this can show up as garbage collector pressure. time.NewTimer plus Reset avoids the allocation but costs a few lines of code.
A select waits for any ready case. If one of your cases is on a channel that nothing will ever close or send to, that case does nothing but still gets evaluated every time. That's usually fine. The bigger problem is goroutines that never exit because their select is stuck waiting on a channel that no one will ever signal.
Every long-running goroutine sitting in a for { select {} } loop must have at least one case wired to a cancellation signal (a closed done channel, a context.Context, or similar). Without that, the goroutine leaks when the rest of the program moves on.
There's another trick the language provides for managing which cases are active: setting a channel variable to nil disables that case in the select without removing it from the code. Just know that this is the toolkit Go gives you for shaping which cases participate.
10 quizzes