select & Multiplexing Channels

Reviewed & published by Brayan K

select lets one goroutine wait on several channel operations at once and act on whichever becomes ready first — the tool you use to add timeouts, non-blocking checks, cancellation, and to merge many channels into one.

Part of the free Go course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.

What You'll Learn in This Lesson

1️⃣ select over multiple channels

A select block lists several channel operations as cases. It blocks until one of them can proceed, then runs that single case. If more than one is ready it chooses at random — so to keep an example deterministic we arrange for exactly one case to be ready at a time. Below, pick runs one select per call, and we feed the channels in a fixed order.

package main

import "fmt"

func main() {
    a := make(chan int, 1)
    b := make(chan int, 1)

    // select is a "switch for channels": it waits on several channel
    // operations and runs the case that is ready. To stay DETERMINISTIC
    // we hand it work in a fixed order — exactly one case ready at a time.
    a <- 1
    pick(a, b) // only 'a' has a value -> the 'a' case runs

    b <- 2
    pick(a, b) // only 'b' has a value -> the 'b' case runs

    fmt.Println("done")
}

// pick runs ONE select. Whichever channel currently holds a value wins.
func pick(a, b chan int) {
    select {
    case v := <-a:
        fmt.Println("got from a:", v)
    case v := <-b:
        fmt.Println("got from b:", v)
    }
}

2️⃣ The default case: non-blocking

Add a default and select stops waiting: if no other case can run right now, default runs immediately. This gives you a non-blocking receive ("is there a value? if not, carry on") and a non-blocking send ("try to send; if the buffer is full, drop it"). It's the building block for polling and back-pressure.

package main

import "fmt"

func main() {
    ch := make(chan string, 1)

    // A 'default' case makes select NON-BLOCKING: if no other case is
    // ready right now, default runs instead of waiting.
    select {
    case msg := <-ch:
        fmt.Println("received:", msg)
    default:
        fmt.Println("nothing ready yet") // ch is empty -> this runs
    }

    // Put a value in, and now the receive case wins.
    ch <- "hello"
    select {
    case msg := <-ch:
        fmt.Println("received:", msg) // this runs
    default:
        fmt.Println("nothing ready yet")
    }

    // default also makes a SEND non-blocking. The buffer (cap 1) starts
    // empty, so the first send succeeds; the second hits default.
    out := make(chan int, 1)
    for i := 1; i <= 2; i++ {
        select {
        case out <- i:
            fmt.Println("sent", i)
        default:
            fmt.Println("buffer full, dropped", i)
        }
    }
}

3️⃣ Timeouts and a done channel

Two of the most common select patterns. A timeout races your real result against time.After(d), a channel that fires after duration d — whichever arrives first wins. A done channel signals cancellation: you close it, and because a closed channel makes every <-done return instantly, any number of goroutines can be told to stop with a single close(done).

package main

import (
    "fmt"
    "time"
)

func main() {
    // 1) TIMEOUT: race a slow worker against time.After. The worker takes
    //    200ms but we only allow 50ms, so the timeout case always wins.
    slow := make(chan string)
    go func() {
        time.Sleep(200 * time.Millisecond)
        slow <- "slow result"
    }()

    select {
    case res := <-slow:
        fmt.Println("worker:", res)
    case <-time.After(50 * time.Millisecond):
        fmt.Println("timed out waiting for worker")
    }

    // 2) A DONE channel tells a goroutine to stop. CLOSING it makes every
    //    <-done receive return immediately, so the worker's select exits.
    done := make(chan struct{})
    stopped := make(chan struct{})
    go func() {
        for {
            select {
            case <-done:
                fmt.Println("worker: received stop signal, exiting")
                close(stopped)
                return
            case <-time.After(10 * time.Millisecond):
                // keep working in small ticks until told to stop
            }
        }
    }()

    close(done) // signal: please stop
    <-stopped   // wait until the worker confirms it exited
    fmt.Println("main: worker has stopped cleanly")
}

Fan-in goes one step further: it merges several channels into one. Each input is copied across by its own goroutine, and a WaitGroup closes the merged output when they're all drained. Because the goroutines race, we collect the values and sort them for a stable, printable result.

package main

import (
    "fmt"
    "sort"
    "sync"
)

// fanIn merges several input channels into ONE output channel. Each input
// gets its own goroutine copying values across; a WaitGroup closes the
// output once every input is drained.
func fanIn(inputs ...<-chan int) <-chan int {
    out := make(chan int)
    var wg sync.WaitGroup
    for _, in := range inputs {
        wg.Add(1)
        go func(c <-chan int) {
            defer wg.Done()
            for v := range c {
                out <- v
            }
        }(in)
    }
    go func() {
        wg.Wait()
        close(out)
    }()
    return out
}

func source(vals ...int) <-chan int {
    c := make(chan int, len(vals))
    for _, v := range vals {
        c <- v
    }
    close(c)
    return c
}

func main() {
    a := source(1, 3, 5)
    b := source(2, 4, 6)

    // Merge order is NOT fixed (goroutines race), so we COLLECT then SORT
    // to get a stable, deterministic result to print.
    var merged []int
    for v := range fanIn(a, b) {
        merged = append(merged, v)
    }
    sort.Ints(merged)
    fmt.Println("merged:", merged)
}

🎯 Your Turn

Wire up a result-vs-timeout race. Fill in the two blanks marked ___ using the hints, then run it.

package main

import (
    "fmt"
    "time"
)

func main() {
    // 🎯 YOUR TURN — fill in the blanks marked ___

    result := make(chan string)
    go func() {
        time.Sleep(10 * time.Millisecond)
        result <- "ok"
    }()

    select {
    // 1) Receive the worker's value into res
    case res := ___:           // 👉 <-result
        fmt.Println("worker:", res)
    // 2) Give up after 1 second using time.After
    case ___:                  // 👉 <-time.After(1 * time.Second)
        fmt.Println("timed out")
    }

    // ✅ Expected output (10ms beats the 1s timeout):
    //    worker: ok
}

Common Errors (and the fix)

❌ Relying on case order. Writing two ready cases and expecting the first to run.

✅ select picks at random among ready cases. Make one ready at a time, or collect-and-sort the outputs.

❌ Busy-waiting with for { select { default: } } and no real case — pins a CPU at 100%.

✅ Add a blocking case (a real channel or time.After) so the loop parks instead of spinning.

❌ Leaking timers: case <-time.After(d) inside a hot loop allocates a fresh timer each pass.

✅ Create one time.NewTimer outside the loop and Reset it, or use context.WithTimeout.

❌ Sending on a channel to cancel many goroutines — only one receiver wakes per send.

✅ Use close(done): a closed channel unblocks every waiting <-done at once.

🧠 Quick Recall

select { case <-time.After(0): "a"; default: "b" }

time.After(0) isn't ready the instant select runs, so default ("b") usually wins. select never waits when a default is present.

Both receives return immediately with the zero value. A closed channel never blocks a receiver — which is exactly why closing a done channel cancels many goroutines at once.

🎯 Mini-Challenge: Non-Blocking Drain

Drain a buffered channel without ranging over it: loop with a select that has a receive case and a default. When default fires, the channel is empty — print drained and break.

package main

import (
    "fmt"
)

func main() {
    // 🎯 MINI-CHALLENGE: non-blocking drain
    // Given a buffered channel pre-loaded with three ints, drain it WITHOUT
    // ranging: loop with a select that has a receive case and a default.
    // When default fires, the channel is empty — print "drained" and break.
    //
    // ✅ Expected output:
    //    got 10
    //    got 20
    //    got 30
    //    drained

    ch := make(chan int, 3)
    ch <- 10
    ch <- 20
    ch <- 30

    // your code here: for { select { case v := <-ch: ...  default: ... } }
}

🎉 Lesson Complete!

Practice quiz

What does a select statement do?

  • Loops over a channel's values
  • Closes all listed channels
  • Waits on several channel operations and runs the one that is ready
  • Sorts channel values

Answer: Waits on several channel operations and runs the one that is ready. select is a switch for channels: it blocks until a case can proceed, then runs it.

If several select cases are ready at the same instant, which one runs?

  • One chosen at random
  • The first listed case
  • The last listed case
  • All of them in order

Answer: One chosen at random. Go picks a ready case at random to prevent starvation.

What does adding a default case to a select do?

  • Makes it block forever
  • Repeats the select
  • Closes the channel
  • Makes it non-blocking — default runs if no other case is ready

Answer: Makes it non-blocking — default runs if no other case is ready. With a default, select never waits: if nothing else is ready, default runs immediately.

How do you add a timeout to a channel operation in a select?

  • case <-time.Sleep(d)
  • case <-time.After(d)
  • default: time.After(d)
  • case timeout(d)

Answer: case <-time.After(d). time.After(d) returns a channel that fires after d; race it against your real case.

Why close a done channel instead of sending on it to cancel goroutines?

  • A closed channel makes every <-done receive return immediately, cancelling many goroutines at once
  • Closing is faster to type
  • Sending is not allowed on done channels
  • Closing sends a value to each receiver

Answer: A closed channel makes every <-done receive return immediately, cancelling many goroutines at once. A closed channel unblocks every waiting receiver; a send reaches only one.

What happens with an empty select {} that has no cases?

  • It returns immediately
  • It runs default
  • It blocks forever (deadlock if nothing else runs)
  • It panics at compile time

Answer: It blocks forever (deadlock if nothing else runs). select {} has nothing it can proceed on, so it blocks forever.

A receive from a closed channel in a select case does what?

  • Blocks until reopened
  • Returns immediately with the zero value
  • Panics
  • Skips the case

Answer: Returns immediately with the zero value. Receiving from a closed channel returns the zero value without blocking.

What is the risk of for { select { default: } } with no other case?

  • A deadlock
  • A compile error
  • A memory leak only
  • A busy-wait that pins a CPU at 100%

Answer: A busy-wait that pins a CPU at 100%. With only a default the loop never parks, spinning and burning a CPU core.

What does select with a default offer for a send like 'case out <- i'?

  • A guaranteed send
  • A non-blocking try-send that hits default if the buffer is full
  • A blocking send forever
  • An automatic retry

Answer: A non-blocking try-send that hits default if the buffer is full. default makes the send non-blocking: if it can't proceed, default runs (a try-send).

What is fan-in?

  • Splitting one channel into many
  • Sorting a channel
  • Merging several input channels into one output channel
  • Closing channels in order

Answer: Merging several input channels into one output channel. Fan-in merges multiple input channels into a single output channel.

Continue this course

Frequently asked questions

What happens if several select cases are ready at the same time?

Go picks one of the ready cases at random. That is deliberate — it prevents one busy channel from starving the others. It also means you must never rely on which case 'wins' when more than one is ready; if you need a fixed order, structure your code so only one case is ready at a time, or collect the results and sort them.

How is a select with a default different from one without?

Without a default, select blocks until one of its cases can proceed. Add a default case and select becomes non-blocking: if no case is ready at that instant, it runs default immediately and moves on. That is how you poll a channel or do a try-send without ever waiting.

How do I add a timeout to a channel operation?

Put a case <-time.After(d) alongside your real case in the select. time.After returns a channel that delivers a value after the duration d, so whichever happens first — your result or the timeout — wins. For repeated timeouts in a loop, prefer time.NewTimer so you can stop and reset it instead of leaking a new timer each iteration.

What is a 'done' channel and why close it instead of sending on it?

A done channel signals cancellation to one or more goroutines. Closing it is better than sending a value because a closed channel makes every <-done receive return immediately, so any number of goroutines can be told to stop with a single close(done). Sending would only reach one receiver per send.

Why does an empty select {} deadlock?

select {} with no cases has nothing it can ever proceed on, so it blocks forever. With every other goroutine also blocked or finished, the runtime reports 'all goroutines are asleep - deadlock!'. You will occasionally see select {} used on purpose to block main forever in a server, but only when other goroutines are doing the real work.

Related lessons