Channels (buffered, unbuffered, close)
Reviewed & published by Brayan K
Channels are how goroutines talk to each other safely. By the end of this lesson you'll send and receive values, tell unbuffered from buffered channels, close a channel and range over it, and use select to wait on several channels at once — Go's "communicate to share memory" philosophy in action.
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
- Create a channel with make and a value type
- Send with ch <- v and receive with v := <-ch
- Tell unbuffered from buffered channels
- Return results from goroutines through channels
- Close a channel and range over it until drained
- Read the comma-ok form to detect a closed channel
- Wait on multiple channels with select
📊 Channel Operations
| Operation | Syntax | Blocks until… |
|---|---|---|
| Create | make(chan int) | — (n/a) |
| Send | ch <- v | a receiver takes it (or buffer has room) |
| Receive | v := <-ch | a value is available |
| Close | close(ch) | — (signals no more sends) |
| Receive (ok) | v, ok := <-ch | ok is false when closed & drained |
Only the sender should close a channel, and never send on a closed channel — both rules keep you out of panics.
1️⃣ Send and Receive (Unbuffered)
Make a channel with make(chan T), send with ch <- v, receive with v := <-ch. An unbuffered channel is a rendezvous: the send blocks until a receiver is ready, and the receive blocks until a value arrives. That handshake also synchronizes the two goroutines for free.
package main
import "fmt"
// A channel is a typed pipe between goroutines. One goroutine SENDS
// with ch <- v, another RECEIVES with v := <-ch.
func main() {
ch := make(chan string) // unbuffered channel of strings
go func() {
ch <- "ping" // send blocks until someone receives
}()
msg := <-ch // receive blocks until someone sends
fmt.Println(msg) // ping
}Channels shine for returning a value out of a goroutine — there's no return across the go boundary, so you send the result back instead.
package main
import "fmt"
// Channels are the idiomatic way to get a RESULT back from a goroutine.
func square(n int, out chan int) {
out <- n * n // send the answer back
}
func main() {
results := make(chan int)
go square(3, results)
go square(4, results)
// Receive two values. Order depends on which goroutine finishes first.
a := <-results
b := <-results
fmt.Println("got two results:", a+b) // 9 + 16 = 25 (in some order)
}Your turn. Fill in the send and the receive so the value travels through the channel.
package main
import "fmt"
func main() {
ch := make(chan int)
go func() {
___ 42 // 👉 ch <- 42 (send 42 into the channel)
}()
value := ___ // 👉 <-ch (receive from the channel)
fmt.Println("received:", value)
// ✅ Expected output: received: 42
}2️⃣ Buffered Channels
Give make a second argument and you get a buffered channel that holds that many values without a receiver waiting. Sends only block once the buffer is full; receives only block once it's empty. len(ch) tells you how many values are queued, cap(ch) the capacity.
package main
import "fmt"
func main() {
// A BUFFERED channel holds up to N values without a receiver waiting.
// make(chan T, capacity)
ch := make(chan string, 2) // capacity 2
ch <- "first" // does NOT block — buffer has room
ch <- "second" // still room
// ch <- "third" // WOULD block: buffer is full
fmt.Println(<-ch) // first
fmt.Println(<-ch) // second
fmt.Println("len after drain:", len(ch), "cap:", cap(ch))
}3️⃣ Closing and Ranging
close(ch) announces that no more values are coming. A receiver can then range over the channel and the loop ends automatically once every queued value has been read. The v, ok := <-ch form sets ok to false when the channel is closed and empty.
package main
import "fmt"
// Closing a channel says "no more values will be sent". Receivers can
// then RANGE over it until it's drained, and the loop ends automatically.
func main() {
nums := make(chan int, 3)
go func() {
for i := 1; i <= 3; i++ {
nums <- i * 10
}
close(nums) // signal: done sending
}()
// range stops cleanly when the channel is closed AND drained.
for n := range nums {
fmt.Println("received", n)
}
// Receiving from a closed channel gives the zero value and ok=false.
v, ok := <-nums
fmt.Println("after close:", v, ok) // after close: 0 false
}4️⃣ Waiting on Many with select
select is like a switch for channels: it blocks until one of its cases is ready, then runs that case. It's the heart of timeouts, fan-in, and cancellation patterns. If several cases are ready at once, Go picks one at random to keep things fair.
🧩 Reorder Challenge
These lines send three numbers, close the channel, and range over it. Put them in the correct order:
A. for i := 1; i <= 3; i++ { ch <- i }
B. ch := make(chan int, 3)
C. for n := range ch {
D. fmt.Println(n)
E. go func() {
F. close(ch)
G. }()
H. }B, E, A, F, G, C, D, H. Make the channel, then a goroutine that sends the three values and closes the channel (so the range can terminate), then range over it in main and print each value. Closing must happen or the range blocks forever.
🧠 Quick Recall
Predict the output before revealing the answer.
ch := make(chan int)
ch <- 1
fmt.Println(<-ch)Deadlock. The channel is unbuffered, so ch <- 1 blocks forever waiting for a receiver — but the receive is on the next line in the same goroutine, which never runs. Send from a separate goroutine, or make the channel buffered.
ch := make(chan int, 1)
ch <- 7
close(ch)
v, ok := <-ch
fmt.Println(v, ok)7 true — closing doesn't discard buffered values. You can still receive the 7 (and ok is true). Only once the buffer is drained does a receive give 0 false.
3. What does this do?
ch := make(chan int)
close(ch)
ch <- 1Panics: "send on closed channel". Once closed, a channel must never be sent to again. This is why only the sender should close, and only when it's truly done.
Pro Tips
- 💡 The sender closes, never the receiver. Closing tells receivers "I'm done"; a receiver closing makes no sense and risks a panic.
- 💡 Unbuffered = synchronization, buffered = decoupling. Reach for unbuffered when you want the handshake; buffer only when you have a clear reason.
- 💡 A nil channel blocks forever. That's occasionally useful in a select to disable a case, but a surprise if accidental.
- 💡 Use select with a time.After case to add timeouts, so a slow producer can't hang your program.
Common Errors (and the fix)
- "all goroutines are asleep - deadlock!" — a send with no receiver (or vice versa). On an unbuffered channel, the other side must be running concurrently.
- "panic: send on closed channel" — you sent after close. Close only once, only from the sender, only when finished.
- "panic: close of closed channel" — you closed the same channel twice. Guard it or restructure so close happens exactly once.
- range never ends — you forgot to close the channel. A for v := range ch only stops when the channel is closed.
- Reading nil from a channel unexpectedly — receiving from a closed, drained channel returns the zero value. Use v, ok := to tell apart a real zero from a closed channel.
Frequently Asked Questions
Q: When should I use a buffered channel?
When the producer and consumer run at different speeds and you want the producer to keep working without waiting for each value to be picked up. Pick a capacity that matches your batch size; an oversized buffer just hides backpressure problems.
Q: Do I have to close every channel?
No. Close only when receivers need to know the stream is finished — typically so a range can stop. If nobody ranges and the channel is simply garbage-collected, you don't need to close it.
Q: Channels or a Mutex?
Use channels to pass ownership of data between goroutines ("communicate to share memory"). Use a Mutex when several goroutines must read and write one shared structure in place. Both are valid; pick the one that makes the data flow clearest.
Q: What does a select with a default case do?
It makes the select non-blocking: if no channel is ready, the default runs immediately instead of waiting. Handy for polling without blocking.
Mini-Challenge: Summing Worker
No blanks — just a brief. Feed numbers into a jobs channel, let a worker goroutine sum them, and send the total back on a done channel. This producer/worker/result shape is the backbone of real Go pipelines.
package main
import "fmt"
func main() {
// 🎯 MINI-CHALLENGE: a worker that sums numbers
// 1. Make a channel jobs := make(chan int, 5) and a channel done := make(chan int).
// 2. Start a goroutine that ranges over jobs, adds them up, and sends
// the total into done.
// 3. In main, send 1..5 into jobs, then close(jobs).
// 4. Receive the total from done and print "sum = 15".
//
// ✅ Expected output: sum = 15
// your code here
}🎉 Lesson Complete!
- ✅ A channel is a typed pipe: send with ch <- v, receive with v := <-ch
- ✅ Unbuffered channels rendezvous; buffered channels hold up to their capacity
- ✅ Channels are the idiomatic way to return results from goroutines
- ✅ close signals "no more sends"; range drains then stops
- ✅ v, ok := <-ch detects a closed, drained channel
- ✅ select waits on several channels at once
Practice quiz
How do you create an unbuffered channel of ints?
- make(chan int)
- make(chan int, 0)
- new(chan int)
- Both make(chan int) and make(chan int, 0)
Answer: Both make(chan int) and make(chan int, 0). make(chan int) and make(chan int, 0) both create an unbuffered channel — capacity zero means no buffer.
On an unbuffered channel, a send (ch <- v) blocks until...
- The buffer is full
- A receiver is ready to take the value
- The channel is closed
- It never blocks
Answer: A receiver is ready to take the value. An unbuffered channel is a rendezvous: the send blocks until a receiver is ready to receive.
What happens with: ch := make(chan int); ch <- 1; fmt.Println(<-ch) in one goroutine?
- Prints 1
- Deadlock
- Prints 0
- Panics
Answer: Deadlock. The unbuffered send blocks waiting for a receiver, but the receive is on the next line in the same goroutine — deadlock.
After ch := make(chan string, 2); ch <- "a"; ch <- "b", what is len(ch) and cap(ch)?
- len 2, cap 2
- len 0, cap 2
- len 2, cap 0
- len 1, cap 2
Answer: len 2, cap 2. Two values are buffered without blocking, so len is 2 and the capacity stays 2.
What does receiving from a closed AND drained channel with v, ok := <-ch give?
- The last value, true
- The zero value, false
- A panic
- Blocks forever
Answer: The zero value, false. Once a channel is closed and empty, receives return the zero value and ok is false.
Given c := make(chan int, 1); c <- 7; close(c); v, ok := <-c — what is v, ok?
- 0, false
- 7, false
- 7, true
- 0, true
Answer: 7, true. Closing doesn't discard buffered values, so you still receive the 7 with ok true; only once drained do you get 0, false.
What does sending on a closed channel do?
- Returns an error
- Silently drops the value
- Panics: send on closed channel
- Reopens the channel
Answer: Panics: send on closed channel. Sending after close panics with 'send on closed channel'. Only the sender should close, and only when done.
Why might a for v := range ch loop never end?
- The channel is buffered
- The channel was never closed
- The values are nil
- select is missing
Answer: The channel was never closed. A range over a channel only stops when the channel is closed. Forget to close it and the loop blocks forever.
What does select do?
- Runs every ready case
- Blocks until one case is ready, then runs that one
- Picks the first case in source order
- Loops over channels
Answer: Blocks until one case is ready, then runs that one. select waits until one of its cases is ready, then runs that case; if several are ready it picks one at random.
What does adding a default case to a select do?
- Makes it loop
- Makes it block longer
- Makes it non-blocking — runs default if nothing is ready
- Closes all channels
Answer: Makes it non-blocking — runs default if nothing is ready. A default case makes select non-blocking: if no channel is ready right now, default runs immediately.
Continue this course
- Previous: Goroutines
- Next: Concurrency with Goroutines — Run code concurrently with goroutines and communicate safely using channels