The select statement is Go's multiplexer for channelWhat is channel?A typed conduit in Go used to pass values between goroutines - can be unbuffered (synchronous) or buffered (async queue). operations. It lets a goroutineWhat is goroutine?A lightweight concurrent function in Go launched with the go keyword - much cheaper than an OS thread (~2KB vs ~1MB of stack). wait on multiple channels simultaneously and proceed with whichever is ready first. It looks like switch but works fundamentally differently, and AI gets those differences wrong regularly.
How select works
select {
case msg := <-ch1:
fmt.Println("from ch1:", msg)
case msg := <-ch2:
fmt.Println("from ch2:", msg)
case ch3 <- outgoing:
fmt.Println("sent to ch3")
}The select blocks until one of the cases can proceed. Cases can be receives or sends.
| Scenario | Behavior |
|---|---|
| One case ready | That case executes |
| Multiple cases ready | One chosen uniformly at random |
| No cases ready, no default | Blocks until a case is ready |
| No cases ready, has default | Executes default immediately |
No cases at all (select{}) | Blocks forever |
The random selection when multiple cases are ready is critical. It's not "first case wins", Go deliberately randomizes to prevent starvation. AI-generated code that depends on case ordering is buggy.
Select with default: non-blocking operations
Adding default makes the select non-blocking:
select {
case msg := <-ch:
process(msg)
default:
// ch has nothing ready - do something else
fmt.Println("no message available")
}This is how you implement "try to receive" or "try to send" without blocking:
// Non-blocking send - drop message if channel is full
select {
case ch <- msg:
// sent
default:
log.Println("channel full, dropping message")
}default in select loops that should block. A for { select { ... default: } } becomes a busy loop that spins the CPU at 100%. If the default does nothing useful, remove it.// BAD - AI generates this. Burns CPU.
for {
select {
case msg := <-ch:
process(msg)
default:
// does nothing, loops immediately, 100% CPU
}
}
// CORRECT - block until message arrives
for {
select {
case msg := <-ch:
process(msg)
case <-ctx.Done():
return
}
}Timeouts
Select with time.After is the standard timeout pattern:
select {
case result := <-longOperation():
fmt.Println("got result:", result)
case <-time.After(5 * time.Second):
fmt.Println("timed out")
}But time.After in a loop is a memory and goroutineWhat is goroutine?A lightweight concurrent function in Go launched with the go keyword - much cheaper than an OS thread (~2KB vs ~1MB of stack). leak:
// BAD - leaks a timer goroutine every iteration
for {
select {
case msg := <-ch:
process(msg)
case <-time.After(30 * time.Second):
fmt.Println("idle timeout")
return
}
}Every iteration creates a new time.After channelWhat is channel?A typed conduit in Go used to pass values between goroutines - can be unbuffered (synchronous) or buffered (async queue). backed by a goroutine that lives until the duration expires. If messages arrive every second, you accumulate 30 leaked timers per second.
// CORRECT - reuse a single timer
timer := time.NewTimer(30 * time.Second)
defer timer.Stop()
for {
select {
case msg := <-ch:
if !timer.Stop() {
<-timer.C
}
timer.Reset(30 * time.Second)
process(msg)
case <-timer.C:
fmt.Println("idle timeout")
return
}
}time.After in loops instead of time.NewTimer. This is one of the most common resource leaks in AI-generated Go code. The program works fine under light load but hemorrhages memory under heavy traffic.| Approach | Allocations per iteration | Goroutine leak? | Use when |
|---|---|---|---|
time.After | New channel + goroutine | Yes, until expiry | One-shot select (no loop) |
time.NewTimer + Reset | Reuses same timer | No | Select in a loop |
time.NewTicker | Reuses same ticker | No (if stopped) | Periodic events |
The for-select loop
The most common pattern in Go concurrent code: a goroutineWhat is goroutine?A lightweight concurrent function in Go launched with the go keyword - much cheaper than an OS thread (~2KB vs ~1MB of stack). that processes events from multiple sources until told to stop:
func worker(ctx context.Context, jobs <-chan Job, results chan<- Result) {
for {
select {
case job, ok := <-jobs:
if !ok {
return // jobs channel closed
}
results <- process(job)
case <-ctx.Done():
return // context cancelled
}
}
}Every for-select loop needs an exit condition. The two standard ones are context cancellation (ctx.Done()) and channelWhat is channel?A typed conduit in Go used to pass values between goroutines - can be unbuffered (synchronous) or buffered (async queue). close (the ok idiom).
Implementing priority with select
Since select chooses randomly among ready cases, you can't express priority directly. The pattern for priority is a nested select:
for {
// First, drain all high-priority items
select {
case msg := <-highPriority:
handleUrgent(msg)
continue
default:
}
// Then check both high and low priority
select {
case msg := <-highPriority:
handleUrgent(msg)
case msg := <-lowPriority:
handleNormal(msg)
case <-ctx.Done():
return
}
}The first select with default is non-blocking, it drains any pending high-priority messages. Only when the high-priority channelWhat is channel?A typed conduit in Go used to pass values between goroutines - can be unbuffered (synchronous) or buffered (async queue). is empty does execution fall through to the second select that checks both channels.
Nil channels in select
A nil channelWhat is channel?A typed conduit in Go used to pass values between goroutines - can be unbuffered (synchronous) or buffered (async queue). in a select case is never ready, effectively disabling that case. This is a powerful dynamic control mechanism:
func merge(ch1, ch2 <-chan int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for ch1 != nil || ch2 != nil {
select {
case v, ok := <-ch1:
if !ok {
ch1 = nil // disable this case
continue
}
out <- v
case v, ok := <-ch2:
if !ok {
ch2 = nil
continue
}
out <- v
}
}
}()
return out
}When a channel is closed, set it to nil to prevent the select from spinning on the zero-value receive. This is the correct way to merge channels that close independently.
range stops, but a raw <-ch receive returns zero values forever. Without the nil-channel trick, you get an infinite stream of zeros.Context integration
Modern Go uses context.Context for cancellation. The ctx.Done() channelWhat is channel?A typed conduit in Go used to pass values between goroutines - can be unbuffered (synchronous) or buffered (async queue). closes when the context is cancelled, making it a natural fit for select:
func fetchWithTimeout(ctx context.Context, url string) ([]byte, error) {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
result := make(chan []byte, 1)
errCh := make(chan error, 1)
go func() {
data, err := http.Get(url)
if err != nil {
errCh <- err
return
}
body, _ := io.ReadAll(data.Body)
result <- body
}()
select {
case data := <-result:
return data, nil
case err := <-errCh:
return nil, err
case <-ctx.Done():
return nil, ctx.Err()
}
}time.After when the function already receives a context. Use context.WithTimeout instead, it integrates with the caller's cancellation chain. A time.After timeout can't be cancelled by the parent context.