LearnAI ToolsCareerPractice BuildsPlayContact
Lesson 3320 min read

Channels

Learn how to create channels with make(chan T), send and receive values, the difference between buffered and unbuffered channels, and the comma-ok idiom.

Introduction

Goroutines can run independently, but they usually need to talk to each other — to hand off a result, signal completion, or coordinate work. Channels are Go's built-in way to do that safely. The Go proverb sums it up well: "Do not communicate by sharing memory; instead, share memory by communicating." Channels are how that communication happens.

What You Will Learn
  • How to create a channel with make(chan T).
  • How to send and receive values through a channel.
  • The difference between buffered and unbuffered channels.
  • How to close a channel and detect that it is closed.
  • How to range over a channel to consume all its values.

Creating Channels

A channel is a typed conduit for values, created with the built-in make function. chan int, for example, is a channel that only carries int values.

ch := make(chan int) // unbuffered channel of int
names := make(chan string, 3) // buffered channel of string, capacity 3

Sending and Receiving

The <- operator both sends and receives, depending on which side of the channel it appears. ch <- value sends value into the channel; value := <-ch receives a value from the channel.

package main
import "fmt"
func worker(ch chan string) {
ch <- "work is done" // send
}
func main() {
ch := make(chan string)
go worker(ch)
result := <-ch // receive (blocks until a value arrives)
fmt.Println(result)
}
Output

Click Run to see what this code prints.

This example needs no time.Sleep at all: the receive on the main goroutine blocks until the worker goroutine sends a value, which is exactly the synchronization channels give you for free.

Buffered vs Unbuffered Channels

An unbuffered channel (make(chan T)) has no storage capacity — a send blocks until another goroutine is ready to receive, and vice versa. This makes unbuffered channels a synchronization point as much as a data pipe. A buffered channel (make(chan T, n)) can hold up to n values without a receiver being ready; a send only blocks once the buffer is full.

package main
import "fmt"
func main() {
buffered := make(chan int, 2)
buffered <- 1 // does not block, buffer has room
buffered <- 2 // does not block, buffer now full
fmt.Println(<-buffered)
fmt.Println(<-buffered)
}
Output

Click Run to see what this code prints.

TypeBehavior
Unbuffered — make(chan T)Send blocks until a receiver is ready; strong synchronization guarantee.
Buffered — make(chan T, n)Send only blocks once n unreceived values are queued; looser coupling.

Closing a Channel

A sender can close a channel with close(ch) to signal that no more values will be sent. Receivers can still drain any values already in the buffer after a close, but sending on a closed channel panics.

package main
import "fmt"
func main() {
ch := make(chan int, 3)
ch <- 1
ch <- 2
ch <- 3
close(ch)
for i := 0; i < 3; i++ {
fmt.Println(<-ch)
}
}
Output

Click Run to see what this code prints.

The Comma-Ok Idiom

Receiving from a channel supports a second return value that reports whether the channel is still open. This is the same comma-ok pattern used for map lookups and type assertions.

package main
import "fmt"
func main() {
ch := make(chan int, 2)
ch <- 10
close(ch)
v, ok := <-ch
fmt.Println(v, ok) // value still buffered, channel open-ish: ok is true
v, ok = <-ch
fmt.Println(v, ok) // channel drained and closed: ok is false
}
Output

Click Run to see what this code prints.

Once a closed channel is fully drained, every further receive returns the zero value of its type along with ok set to false — this is how a receiver detects "there is truly nothing left to read."

Ranging Over a Channel

A for...range loop over a channel receives values one at a time and automatically stops when the channel is closed and drained — no manual comma-ok checking required.

package main
import "fmt"
func generateSquares(n int) <-chan int {
ch := make(chan int)
go func() {
defer close(ch)
for i := 1; i <= n; i++ {
ch <- i * i
}
}()
return ch
}
func main() {
for square := range generateSquares(5) {
fmt.Println(square)
}
}
Output

Click Run to see what this code prints.

generateSquares returns a receive-only channel (<-chan int), a common and idiomatic way to expose a channel that callers should only read from, never send to or close.

Common Mistakes

Avoid These Mistakes
  • Sending on a closed channel, which panics immediately.
  • Closing a channel more than once, which also panics.
  • Closing a channel from the receiving side instead of the sending side.
  • Forgetting that an unbuffered send blocks forever if no one ever receives, causing a deadlock.
  • Ranging over a channel that is never closed, causing the loop to hang forever.

Best Practices

  • Only the sender should close a channel, never the receiver.
  • Use directional channel types (chan<- T, <-chan T) in function signatures to document intent.
  • Prefer unbuffered channels for pure synchronization; use buffered channels to decouple producer and consumer speed.
  • Always have a clear plan for how and when a channel gets closed.
  • Use the comma-ok idiom or range instead of assuming a channel always has data.

Frequently Asked Questions

No. You only need to close a channel when receivers need to know it is finished, such as when ranging over it. Channels are garbage collected like any other value once nothing references them.

It blocks forever. A nil channel (the zero value of a channel variable) is never ready to send or receive, which is occasionally used deliberately to disable a case in a select statement.

Yes, channels are safe for concurrent use by multiple goroutines sending and receiving at the same time — that safety is built into the channel implementation itself.

Key Takeaways

  • Channels are typed conduits created with make(chan T) for communicating between goroutines.
  • Unbuffered channels synchronize sender and receiver; buffered channels allow some slack.
  • close(ch) signals that no more values will be sent, and sending afterward panics.
  • The comma-ok idiom (v, ok := <-ch) detects whether a channel is closed and drained.
  • range over a channel automatically receives values until the channel closes.

Summary

Channels turn goroutine coordination from a shared-memory hazard into a straightforward message-passing model. Whether you need tight synchronization with an unbuffered channel or a bit of slack with a buffered one, channels are the idiomatic way goroutines exchange data safely. Next, you will learn select — the tool for working with multiple channels at once.

Next Lesson →

Select Statement