select in Go is a control structure that waits for operations on several channels and executes the first one that is ready.
If channels are how goroutines talk to each other, select is the switchboard. It allows us to listen to several conversations at once without getting stuck staring at a single channel.
In the previous article, we saw how to send and receive values through channels. Now we will take a step further: coordinating several channels, adding maximum wait times, and closing goroutines in an orderly fashion.
The problem
Suppose we have two goroutines doing independent work. One queries a slow API, and the other reads data from a local cache.
We want to use the first response that arrives, without knowing in advance which one it will be.
With a normal channel read, we would do this:
resultado := <-canalAPIThat works, but it has an obvious problem: we are stuck waiting for canalAPI even if the cache responds first.
Basic syntax of select
select looks a lot like a switch, but each case is a channel operation.
select {
case valor := <-canalA:
fmt.Println("Received from A:", valor)
case valor := <-canalB:
fmt.Println("Received from B:", valor)
}Go evaluates the cases and executes the first one that can proceed. If none is ready, select blocks until one is.
If several channels are ready at the same time, Go picks one pseudo-randomly. Do not rely on the order of the case statements.
Example with two workers
Let’s simulate two tasks that take different amounts of time.
package main
import (
"fmt"
"time"
)
func task(name string, wait time.Duration) <-chan string {
// The buffer prevents leaving the goroutine blocked if another case wins the select.
ch := make(chan string, 1)
go func() {
time.Sleep(wait)
ch <- name + " finished"
}()
return ch
}
func main() {
fast := task("fast", 500*time.Millisecond)
slow := task("slow", 2*time.Second)
select {
case msg := <-fast:
fmt.Println(msg)
case msg := <-slow:
fmt.Println(msg)
}
}In this case, it will print the result of the fast task, because its channel receives first. The slow task is not cancelled: it will finish later and leave its result in the buffer.
The task function returns a read-only channel (<-chan string). Internally, it launches a goroutine that waits for a time and sends a message. In main, select listens to both channels and proceeds with the first one that receives a value.
Adding timeouts
One of the most common uses of select is to avoid infinite waits.
For this, we use time.After, which returns a channel that receives a value after the specified time has passed.
select {
case respuesta := <-canalRespuesta:
fmt.Println("Response:", respuesta)
case <-time.After(2 * time.Second):
fmt.Println("Timeout expired")
}This is very useful in servers, HTTP clients, workers, and any system that depends on external resources.
A timeout does not automatically cancel work that is already running. It only makes the waiter stop waiting. To truly cancel, the usual approach is to propagate a context.Context.
The default case
select also accepts a default block.
select {
case valor := <-ch:
fmt.Println("Received:", valor)
default:
fmt.Println("Nothing yet")
}When we use default, the select does not block. If no channel is ready, it executes default and continues.
This can be useful, but use it carefully. Inside a loop, it can create a busy wait that consumes CPU without doing useful work. Usually, it’s better to block, use a time.Ticker, or rethink the coordination.
Sending with select
The case statements are not only for receiving. We can also send to channels.
select {
case trabajos <- "process image":
fmt.Println("Job sent")
case <-time.After(1 * time.Second):
fmt.Println("Could not send the job")
}This makes sense when the channel might be full or the receiver might not be available. We don’t get stuck forever.
Cancellation pattern
A very common pattern is to have a cancellation channel, usually called done.
func worker(done <-chan struct{}, jobs <-chan string) {
for {
select {
case job, ok := <-jobs:
if !ok {
return
}
fmt.Println("Processing:", job)
case <-done:
fmt.Println("Exiting...")
return
}
}
}We use struct{} because we don’t need to send data. To notify all receivers of the cancellation, the responsible party usually closes the done channel.
In modern code, for cancellation between APIs and goroutines, it is generally better to use context.Context. Still, understanding the done channel helps a lot in understanding how everything works underneath.