Go/
Lesson

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.

ScenarioBehavior
One case readyThat case executes
Multiple cases readyOne chosen uniformly at random
No cases ready, no defaultBlocks until a case is ready
No cases ready, has defaultExecutes 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.

AI pitfall
AI sometimes writes select statements assuming the first case has priority. It doesn't. If you need priority, you need nested selects (covered below). Code that relies on case ordering has a subtle race condition that passes testing but fails under load.
02

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")
}
AI pitfall
AI puts 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
    }
}
03

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
    }
}
AI pitfall
AI almost always uses 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.
ApproachAllocations per iterationGoroutine leak?Use when
time.AfterNew channel + goroutineYes, until expiryOne-shot select (no loop)
time.NewTimer + ResetReuses same timerNoSelect in a loop
time.NewTickerReuses same tickerNo (if stopped)Periodic events
04

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).

AI pitfall
AI generates for-select loops without exit conditions. The goroutine runs forever. Always verify: "How does this loop terminate?"
05

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.

AI pitfall
AI generates "priority select" by just listing the priority channel as the first case. This does not work. Select randomizes. You need the nested pattern above, and AI rarely generates it correctly.
06

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.

AI pitfall
AI often writes channel merge functions that don't handle channel closure. When one input channel closes, range stops, but a raw <-ch receive returns zero values forever. Without the nil-channel trick, you get an infinite stream of zeros.
07

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()
    }
}
AI pitfall
AI generates timeout logic with 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.