Select

select lets a goroutine wait on several channel operations at once and continue with whichever is ready first. With it you can receive from multiple channels, try a send or receive without blocking, give up after a timeout, and stop a goroutine on request.

This lesson builds on Channels, so make sure sending, receiving and closing feel familiar.

Waiting on two channels

A plain receive like <-a waits on one channel only. If a value shows up on b first, you’re stuck waiting for a anyway. select looks like a switch, but each case is a channel operation. It blocks until one of them can proceed, runs that case, and is done.

package main

import (
	"fmt"
	"time"
)

func main() {
	fast := make(chan string)
	slow := make(chan string)

	go func() {
		time.Sleep(50 * time.Millisecond)
		slow <- "slow result"
	}()
	go func() {
		time.Sleep(10 * time.Millisecond)
		fast <- "fast result"
	}()

	for range 2 {
		select {
		case msg := <-fast:
			fmt.Println("from fast:", msg)
		case msg := <-slow:
			fmt.Println("from slow:", msg)
		}
	}
}Code language: Go (go)
from fast: fast result
from slow: slow resultCode language: plaintext (plaintext)

The loop runs select twice. The first time, only fast has a value after 10 milliseconds, so that case runs. The second time, select waits until slow delivers. Each select runs exactly one case.

When several cases are ready

If more than one case can proceed, select picks one at random. It doesn’t prefer the first case in the source. This program makes both channels ready every time and counts which case wins:

package main

import "fmt"

func main() {
	a := make(chan int, 1)
	b := make(chan int, 1)
	counts := map[string]int{}

	for range 1000 {
		a <- 1
		b <- 1
		select {
		case <-a:
			counts["a"]++
			<-b
		case <-b:
			counts["b"]++
			<-a
		}
	}
	fmt.Println(counts)
}Code language: Go (go)
map[a:501 b:499]Code language: plaintext (plaintext)

Your numbers will differ from run to run, but they’ll be close to half and half. The random choice keeps one busy channel from starving the others. It also means you should never rely on the order of cases for priority.

default: don’t wait at all

A select with a default case never blocks. If no other case is ready right now, default runs instead. That turns any send or receive into a “try” operation.

package main

import "fmt"

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

	// Try to receive: nothing is there yet.
	select {
	case msg := <-messages:
		fmt.Println("received", msg)
	default:
		fmt.Println("no message")
	}

	// Try to send twice: the buffer only has room for one.
	for _, msg := range []string{"first", "second"} {
		select {
		case messages <- msg:
			fmt.Println("sent", msg)
		default:
			fmt.Println("buffer full, dropped", msg)
		}
	}

	fmt.Println("received", <-messages)
}Code language: Go (go)
no message
sent first
buffer full, dropped second
received firstCode language: plaintext (plaintext)

The non-blocking send is handy for things like status updates, where dropping a message is better than making the sender wait. Be careful with default inside a loop, though: a loop that keeps hitting default spins and burns a CPU core doing nothing useful. If you find yourself writing that, you probably want to block instead.

Timeouts with time.After

time.After(d) returns a channel that receives the current time once d has passed. Put it in a select next to the operation you’re waiting for, and you have a timeout:

package main

import (
	"fmt"
	"time"
)

func fetch(delay time.Duration) <-chan string {
	ch := make(chan string, 1)
	go func() {
		time.Sleep(delay) // pretend to do slow work
		ch <- fmt.Sprintf("done after %v", delay)
	}()
	return ch
}

func main() {
	for _, delay := range []time.Duration{20 * time.Millisecond, 200 * time.Millisecond} {
		select {
		case result := <-fetch(delay):
			fmt.Println(result)
		case <-time.After(100 * time.Millisecond):
			fmt.Println("timed out waiting for", delay)
		}
	}
}Code language: Go (go)
done after 20ms
timed out waiting for 200msCode language: plaintext (plaintext)

The first fetch answers well within 100 milliseconds. The second one is too slow, so the time.After case wins and main moves on.

Look at the buffer in fetch: make(chan string, 1). When the timeout wins, nobody will ever receive from that channel. With an unbuffered channel, the slow goroutine would block on its send forever, a goroutine leak. With room for one value, it can send and exit even though no one is listening. That’s a small detail worth remembering whenever a receiver might give up.

Older advice warned against time.After in loops because each unfired timer stayed in memory until it fired. Since Go 1.23, timers that are no longer referenced can be garbage collected right away, so that concern is gone. For timeouts that span a whole operation, such as a request with several steps, the standard tool is a context, covered in Context.

Doing something regularly with a ticker

A time.Ticker sends on its channel C again and again, at a fixed interval. Combined with select, it lets a loop do periodic work until something else happens:

package main

import (
	"fmt"
	"time"
)

func main() {
	ticker := time.NewTicker(100 * time.Millisecond)
	defer ticker.Stop()

	stop := time.After(350 * time.Millisecond)
	ticks := 0
	for {
		select {
		case <-ticker.C:
			ticks++
			fmt.Println("tick", ticks)
		case <-stop:
			fmt.Println("stopping after", ticks, "ticks")
			return
		}
	}
}Code language: Go (go)
tick 1
tick 2
tick 3
stopping after 3 ticksCode language: plaintext (plaintext)

The ticker fires at 100, 200 and 300 milliseconds, and the stop channel fires at 350. defer ticker.Stop() stops the ticker when you’re done with it. The return leaves main; a plain break would only leave the select, not the for loop.

for-select with a done channel

That loop shape, for { select { … } }, is the backbone of most long-running goroutines in Go. One case does the work and another listens for a signal to stop. The classic stop signal is a done channel that gets closed. Closing is the right tool because a receive from a closed channel succeeds immediately, for every goroutine that’s listening.

Here a generator produces numbers forever, until main says it has seen enough:

package main

import "fmt"

func count(done <-chan struct{}) <-chan int {
	out := make(chan int)
	go func() {
		defer close(out)
		for i := 1; ; i++ {
			select {
			case out <- i:
			case <-done:
				return
			}
		}
	}()
	return out
}

func main() {
	done := make(chan struct{})
	numbers := count(done)

	for n := range numbers {
		fmt.Println(n)
		if n == 3 {
			break
		}
	}

	close(done)
	for range numbers {
		// Drain until the generator closes its channel.
	}
	fmt.Println("generator stopped")
}Code language: Go (go)
1
2
3
generator stoppedCode language: plaintext (plaintext)

A few things to notice:

  • The generator’s send is a select case. Without that, it would block forever on out <- 4 once main stopped reading, and leak.
  • chan struct{} is the usual type for a pure signal. An empty struct takes no memory, and the type tells readers that no data travels through it.
  • After close(done), the generator returns and its defer close(out) runs. The drain loop in main ends at that moment, which proves the goroutine really stopped. (It may still receive a few more numbers first: when both cases are ready, the generator’s select picks one at random. That’s why I don’t print inside it.)

In real code you’ll usually use a context.Context instead of a hand-made done channel. It works exactly the same way under the hood (ctx.Done() is a channel that gets closed), and it’s the topic of the Context lesson.

Turning off a case with a nil channel

One more trick. Sending to or receiving from a nil channel blocks forever, so a select case on a nil channel can never be chosen. Setting a channel variable to nil switches that case off. This is useful when you merge several channels and one of them closes before the others:

package main

import "fmt"

func send(nums ...int) <-chan int {
	ch := make(chan int)
	go func() {
		defer close(ch)
		for _, n := range nums {
			ch <- n
		}
	}()
	return ch
}

func main() {
	a := send(1, 2, 3)
	b := send(10, 20)

	sum, received := 0, 0
	for a != nil || b != nil {
		select {
		case n, ok := <-a:
			if !ok {
				a = nil // a is closed: stop selecting on it
				continue
			}
			sum += n
			received++
		case n, ok := <-b:
			if !ok {
				b = nil
				continue
			}
			sum += n
			received++
		}
	}
	fmt.Println("received", received, "values, sum", sum)
}Code language: Go (go)
received 5 values, sum 36Code language: plaintext (plaintext)

Without the nil assignment, a closed channel would be ready all the time (returning zero values with ok == false), and the loop would spin on it. The values from a and b arrive interleaved in no fixed order, which is why I print a sum rather than the sequence.

Practice

1. First answer wins. Write a function ask(name string, delay time.Duration) <-chan string that answers name on a buffered channel after delay. Ask two “servers”, "primary" (80ms) and "backup" (30ms), and use one select to print whichever answers first. Add a 50ms timeout case for safety.

Show a solution
package main

import (
	"fmt"
	"time"
)

func ask(name string, delay time.Duration) <-chan string {
	ch := make(chan string, 1)
	go func() {
		time.Sleep(delay)
		ch <- name
	}()
	return ch
}

func main() {
	primary := ask("primary", 80*time.Millisecond)
	backup := ask("backup", 30*time.Millisecond)

	select {
	case name := <-primary:
		fmt.Println("answer from", name)
	case name := <-backup:
		fmt.Println("answer from", name)
	case <-time.After(50 * time.Millisecond):
		fmt.Println("no answer in time")
	}
}Code language: Go (go)
answer from backupCode language: plaintext (plaintext)

2. Count dropped events. Make an events channel with a buffer of 3. In a loop, try to send the numbers 1 to 10 without blocking, counting how many were sent and how many were dropped. Then print both counts and the values that made it into the buffer (receive them until the buffer is empty, using select with default to stop).

Show a solution
package main

import "fmt"

func main() {
	events := make(chan int, 3)
	sent, dropped := 0, 0

	for i := range 10 {
		select {
		case events <- i + 1:
			sent++
		default:
			dropped++
		}
	}
	fmt.Println("sent:", sent, "dropped:", dropped)

	for {
		select {
		case e := <-events:
			fmt.Println("event", e)
		default:
			fmt.Println("buffer empty")
			return
		}
	}
}Code language: Go (go)
sent: 3 dropped: 7
event 1
event 2
event 3
buffer emptyCode language: plaintext (plaintext)

Key takeaways

  • select waits until one of its channel operations can proceed and runs exactly that case. If several are ready, it picks one at random.
  • A default case makes the select non-blocking. Don’t spin on it in a loop.
  • case <-time.After(d) adds a timeout. Give result channels a buffer of 1 so abandoned senders can still exit.
  • for { select { … } } with a closed done channel is the standard way to run and stop a long-lived goroutine.
  • A case on a nil channel never fires, so setting a channel to nil disables its case.