Channels in Go
Typed pipes for passing values safely between goroutines, and the core pattern that makes Go's concurrency model distinctive.
3 min read
A channel is a typed conduit for sending and receiving values between goroutines. It's Go's primary answer to the hardest problem in concurrent programming — safely sharing data between things running at the same time — and it's built into the language itself, not bolted on as a library.
Creating and using a channel
ch := make(chan string)
go func() {
ch <- "hello from goroutine" // send
}()
msg := <-ch // receive (blocks until something is sent)
fmt.Println(msg)chan string is a channel that carries string values. <- is the channel operator: ch <- value sends, <-ch receives. By default, both operations block: a send waits until something is ready to receive, and a receive waits until something is sent. That blocking behavior is exactly what let this simple example work correctly without time.Sleep — the main goroutine's <-ch genuinely waits for the goroutine to produce a value, no timing guesswork involved.
A worker sending results back
func square(n int, results chan<- int) {
results <- n * n
}
func main() {
results := make(chan int)
for i := 1; i <= 5; i++ {
go square(i, results)
}
for i := 0; i < 5; i++ {
fmt.Println(<-results)
}
}chan<- int in the function signature means "a channel you can only send to" — a directional channel that documents intent and lets the compiler catch a goroutine that mistakenly tries to receive from a channel meant purely as its output. <-chan int is the receive-only counterpart. Note results arrive in whatever order the goroutines happen to finish — channels don't guarantee ordering across separate goroutines.
Buffered channels
By default channels are unbuffered — a send blocks until a receiver is ready. A buffered channel can hold a fixed number of values before blocking:
ch := make(chan int, 3) // buffer size 3
ch <- 1
ch <- 2
ch <- 3
// ch <- 4 would block here -- buffer is full
fmt.Println(<-ch, <-ch, <-ch)Buffering is useful when a producer can reasonably run ahead of a consumer by a bounded amount, but it's not a substitute for actually coordinating completion — a common beginner mistake is reaching for a big buffer to "avoid" blocking issues that a different design would solve more directly.
Closing channels and range
A sender can close a channel to signal "no more values are coming":
func generate(ch chan<- int) {
for i := 1; i <= 5; i++ {
ch <- i
}
close(ch)
}
func main() {
ch := make(chan int)
go generate(ch)
for n := range ch {
fmt.Println(n)
}
// range exits automatically once the channel is closed and drained
}Only the sender should ever close a channel, never the receiver — closing a channel you're only receiving from, or closing an already-closed channel, causes a panic. Receiving from a closed channel after it's drained returns the zero value immediately rather than blocking, which is exactly what lets range know to stop.
select: waiting on multiple channels
select lets a goroutine wait on several channel operations at once, proceeding with whichever is ready first:
select {
case msg1 := <-ch1:
fmt.Println("received from ch1:", msg1)
case msg2 := <-ch2:
fmt.Println("received from ch2:", msg2)
case <-time.After(2 * time.Second):
fmt.Println("timed out")
}This pattern — racing a real channel against time.After — is the idiomatic way to add a timeout to any channel operation in Go, and it comes up constantly in real networked code.
Channels take practice to feel natural, but they're the mechanism that makes Go's concurrency model feel fundamentally different from lock-heavy threading in other languages — you coordinate by passing values through a pipe, not by carefully guarding shared variables.
Test what you just learned
4 quick questions. Get all of them right to unlock the next lesson.
You can take the quiz without an account — logging in just lets your result count toward your progress.