Right, so you’ve got a select statement with multiple cases that are all ready to fire at the same time. What happens? Chaos? Anarchy? A coin flip in the heart of the Go runtime?

Precisely. It’s a coin flip.

This is one of those beautiful, pragmatic, and occasionally infuriating design choices Go makes. The language spec doesn’t dictate a strict, predictable order. Instead, when multiple cases in a select are ready to proceed—meaning multiple channels have data to receive or are ready to send—one is chosen pseudo-randomly. I say “pseudo-randomly” because it’s not truly random (it’s deterministic from the runtime’s perspective), but from your code’s perspective, it’s effectively random. You can’t predict it.

Why Randomization is a Feature, Not a Bug

You might be thinking, “That’s horrifying. I need predictability!” But trust me, you don’t. The alternative is a defined order, which is a trap. If the language defined a fixed order (e.g., top-to-bottom evaluation), you’d inevitably start to write code that unconsciously depended on it. This is a classic source of Heisenbugs—bugs that appear or disappear when you innocently refactor the order of your case statements. The randomization forces you to structure your program correctly from the start. You are forced to assume any ready case can be chosen, which is the only safe assumption. It prevents you from writing lazy, order-dependent code that would break the second another developer glanced at it wrong.

Seeing the Randomness in Action

Let’s make it dance. The classic way to demonstrate this is to fire off a bunch of goroutines that all complete at roughly the same time.

package main

import (
    "fmt"
    "time"
)

func main() {
    ch1 := make(chan string)
    ch2 := make(chan string)
    ch3 := make(chan string)

    // Launch three goroutines that all close at the same time
    go func() { ch1 <- "one" }()
    go func() { ch2 <- "two" }()
    go func() { ch3 <- "three" }()

    // Give them a moment to all get ready. This is a hack for demonstration.
    // In a real system, they'd be ready due to logic, not a sleep.
    time.Sleep(10 * time.Millisecond)

    select {
    case msg := <-ch1:
        fmt.Println("Received", msg)
    case msg := <-ch2:
        fmt.Println("Received", msg)
    case msg := <-ch3:
        fmt.Println("Received", msg)
    }
}

Run this a few times. You’ll get different outputs. “one”, “two”, “three”—it’s a surprise every time! This proves the point beautifully.

The Cardinal Sin: Trying to Game the System

Never, ever write a select where the logic changes based on which of multiple ready cases is chosen. If you find yourself needing to do this, your design is wrong. The correct solution is almost always to restructure your channels.

A common pitfall is trying to use a select with a default case to check for channel readiness. Remember: default is taken immediately if no other case is ready. If one or more cases are ready, the select will choose one of them pseudo-randomly, and the default case is completely ignored. It doesn’t get a vote.

// This is a non-blocking check.
select {
case msg := <-ch:
    fmt.Println("received", msg)
default:
    fmt.Println("nothing ready") // This only runs if 'ch' is NOT ready.
}
// In the example above, if 'ch' has a message, the default case is never even considered.

When Randomization Doesn’t Apply

It’s crucial to understand when this rule doesn’t come into play. The randomization only happens when multiple cases are ready simultaneously. The most common select you’ll write probably involves one channel operation and a <-ctx.Done().

select {
case data := <-someCh:
    // process data
case <-ctx.Done():
    // handle cancellation
}

In this scenario, it’s extremely rare for both someCh to have data and for the context to be canceled at the exact same instant. It’s not impossible, but it’s a cosmic alignment of events. If it does happen, the runtime will pick one, and you must be comfortable with that. Your program’s logic should be robust enough that either outcome is acceptable. (e.g., if you get data but the context is canceled, maybe you process the data anyway, or perhaps you abandon it and exit. The point is, you’ve consciously decided what to do).