Channels in Go (Almost all about it)
I know I should not talk about channels in go before talking about go routines so here’s a little about it: A goroutine is just a funciton call with go in front of it: g…
I know I should not talk about channels in go before talking about go routines so here’s a little about it:
A goroutine is just a funciton call with go in front of it:
go doWork()
It means:
- your program starts doWork() in the background
- and the next line of code keeps running immedietly
- You can think of goroutines as helpers and channels are as walkie-talkies that go routines use to pass things around safely, without fighting over the same data.
that’s it. that is all you need to be able to understand about channels,
[I will write on go routines in detail next]

The problem: Two Cooks, One Kitchen
Imagine you’re running a restaurant. You have two cooks in the kitchen. One prepares the starters, and the other prepares the main course. If they work one after the other, your customers wait forever, right?
But if they work at the same time, your restaurant runs smoothly.
This is called Concurrency: doing multiple things simultaneously (or at least appearing to do so).
In go, each “cook” is called a goroutine. They are lightweight threads managed by the Go runtime. You can spin up thousands of them with almost zero cost.
func sendEmail(message string){
go func(){
time.Sleep(time.Millisecond * 250)
fmt.Printf("Email recieved: %s\n", message)
}()
fmt.Printf("Email sent: %s\n", message)
}
The go keyword says: start this and move on… don’t wait for it.
But the problem is if two cooks work seperately how do they talk to each other?
That’s where channels come in.
Channels: The Kitchen Pass-Through
Picture that little window between the kitchen and the dining area in a restaurant… yeah the pass through. Remember how the cook puts food on it. The waiter picks it up. They don’t have to look at each other or shout across the room.
They just use a window.
A Channel in Go is exactly that pass-through. It’s a pipe that lets goroutines send values to each other safely.
ch := make(chan string) //creating a channel that carries strings
go func(){ // One goroutine sends a value into the channel
ch <- "your starter is ready" //similar to putting it on the pass through
}()
message := <- ch //the main goroutine recieves it (picks it up from the pass-through)
fmt.Println(message)
The <- arrow shows the direction of the data flow:
- ch<-value means “send value into channel”
- value := <-ch means “recieve value from the channel”
Easy. The arrow always points in the direction the data travels.
Channels are Blocking (By Design)
Here’s the most imp thing to understand about channels: they block.
If you send to a channel, your goroutine waits until someone receives.
If you receieve from a channel, your goroutine waits until someone sends.
This is actually a superpower, not a limitation. It’s like the pass-through window — the cook has to stop and wait for the waiter if the window is full. The waiter has to stop and wait if the window is empty.
This design adheres to natural synchronization and keeps everything orderly.
//let's see an example first
type email struct{
body string
date time.Time
}
func checkEmailAge(emails [3]email) [3]bool{
isOldChan := make(chan bool)
go sendIsOld(isOldChan, emails) //goroutine checks emails and sends results
isOld := [3]bool{}
isOld[0] := <-isOldChan // wait and recieve res 1
isOld[1] := <-isOldChan // wait and rec res 2
isOld[2] := <-isOldChan // wait and rec res 3
return isOld
}
func sendIsOld(isOldChan chan<- bool, emails [3]email){
for _, e:=range emails{
if e.date.Before(time.Date(2020, 0, 0, 0, 0, 0, 0, time.UTC)){
isOldChan <- true
continue
}
isOldChan <- false
}
}
In above example our main checkEmailAge function launches a goroutine to process emails, then waits for each result one by one. It’s like asking your assistant to check three folders… you wait at your desk until they bring you the answer from each folder, one at a time.
Directional Channels: One-Way Streets
Notice something in the code above? sendIsOld takes chan<- bool , not chan bool .
Go lets you restrict a channel to only send or only recieve. think of it like a one-way street.

func onlySends (ch chan<- string){
ch<- "hellow" // fine
//here you can not use <-ch to read the data from channel cause the ch is write only
}
func onlyRecieves( ch <-chan string){
msg := <-ch // fine
//here you cannot write ch<- "hellow" cause it is receieve-only channel
}
This is a safety feature. You hand a goroutine a one-way channel so it can’t accidently misuse it. Just like you wouldn’t give a delivery driver the keys to your house. You’d give them a one-way delivery slot.
Waiting for multiple Goroutines
Here’s another pattern: waiting for multiple goroutines to “check in” before you move on.
Think of it like a school roll call. The teacher doesn’t move on until every student has said “present”.
func waitForDBs(numDBs int, dbChan chan struct{}){
for i:= 0; i<numDBs; i++{
<-dbChan
}
}
func getDBsChannel(numDBs int) (chan struct{}, *int){
count:= 0
ch:= make(chan struct{})
go func(){
for i:= 0; i<numDBs; i++{
ch <- struct{}{}
fmt.Printf("Database %v is online\n", i+1)
count++
}
}()
return ch, &count
}
Notice the chan struct{} is a channel carrying empty structs. this is a go idiom for “I just want to signal something happened, I don’t need to pass the actual data.” It’s the cheapest possible in terms of memory.
Buffered Channels: The Email Inbox
Every channel we’ve seen so far is unbuffered. It’s like handing someone a note directly. You have to wait until they take it from your hand.
A Buffered Channel is like an email inbox. You can drop 10 emails in it and walk away. The recipient reads them whenever they’re ready. The inbox holds the messages until they’re picked up.
ch := make(chan string, 5) // creted a buffered channel to hold upto 5 strings
ch <- "first email" //no waiting
ch <- "second email" // no waiting
ch <- "third email" // no waiitng
// now you can read them at any time.
fmt.Println(<-ch) // "first email"
fmt.Println(<-ch) // "second email"
A small exercise for you all reading this would be loading emails into a queue without needing anyone on the other end yet (the function should take an array of emails (strings) and return a channel of string with all the emails loaded).
The key rules of buffered channels:
- Blocks on send only when the buffer is full
- Blocks on recieve only when the buffer is empty
Think of it like a coffee machine with a warming plate that holds 4 cups. The barista can make 4 cups and walk away. Customers pick them up when ready. But if the plate already has 4 cups, the barista has to wait.
Closing Channels: The End of Shift Signal
How doed the receiver know when the sender is done? Imgine a factory conveyor belt. Workers take items off the belt all day. But how do they know the day’s work is finished?
Simple: the shift manager turns off the belt (closes the channel).
func sendReports(numBatches int, ch chan int){
for i:= 0; i<numBatches; i++{
numReports := i*23 + 32%17
ch<- numReports
}
close(ch)
}
func countReports(numSentCh chan int) int {
count := 0
for {
v, open := <- numSentCh
if !open {
break
}
count += v
}
return count
}
When you recieve from a closed channel, the second return value open becomes false . That’s your signal: “The sender is done, stop waiting”.
Important Rules:
*Only the sender should close a channel (never the receiver)
*Sending to a closed channel causes a panic
*Receiving from a closed channel returns the zero value immediately
Range Over Channels: The Elegant way to read
Writing that v, open := <- ch; if !open {break} loop every time gets tedious. Go has a cleaner way: range over a channel.
It’s exactly like ranging over a slice, except it blocks and waits for each new value, and automatically stops when the channel is closed.
Here’s a concurrent Fibonacci number generator for example:
func fibonacci(n int, ch chan int){
x, y := 0, 1
for i:= 0; i<n;i++{
ch<-x
x,y=y, x+y
}
close(ch)
}
func concurrentFib(n int) []int {
ch:= make(chan int)
go fibonacci(n, ch)
result := []int{}
for v:= range ch {
result = append(result, v)
}
return result
}
The for v:= range ch loop:
- Waits for the next value
- Processes it
- Repeats
- Automatically exits when the channel is closed
We can think of it like reading a live news ticker… you keep reading until the ticker says “End of Broadcast”.
Select: The Traffic Controller
Now things get interesting. What if you have multiple channels and want to handle whichever one has data first?
This is exactly like a traffic controller at an intersection. Cars come from the north and the east. The controller watches both roads and directs whichever car arrives first. They don’t stand on just one road ignoring the other.
The select statement in Go works the same way:
func logMessages(chEmails, chSms chan string){
for{
select {
case s, ok := <-chSms:
if ok {
fmt.Println("Sms: ", s)
} else {
return // if means SMS channels is closed and we are done
}
case e, ok := <-chEmails:
if ok {
fmt.Println("Email: ", e)
} else {
return
}
}
}
}
select waits until one of the cases is ready, then runs that case. If multiple cases are ready at the same time, Go picks one at random… which is fair, just like a traffic controller who doesn’t always favour one road.
This is incredibly useful when you’re dealing with multiple data streams simultaneously.
For select to be non blocking we need to include a default case as well. This default branch runs when no other case is ready. This makes the entire select non-blocking. Without default , the select would block until one of the channels has data.
Simple Analogy continuing our traffic controller example:
Without default: I’ll stand here until a car arrives at the intersection
With default: I’ll check the intersection. No cars? I’ll go get coffee and come back.
Ping Pong: Channels Playing Together
Let’s put everything together with a classic example; A ping pong game between goroutines.
Three goroutines pass signals between two channels:
- pinger sends pings down the pings channel
- ponger listens on pings , then sends a pong down the pongs channel
- The main goroutine listens on the pongs and prints results
func pingPong (numPings int){
pings:= make(chan struct{})
pongs:= make(chan struct{})
go ponger(pings, pongs)
go pinger(pings, numPings)
i:= 0
for range pongs {
fmt.Println("got pong", i)
i++
}
fmt.Println("pongs done")
}
func pinger(pings chan struct{}, numPings int){
for i:= 0; i<numPings; i++ {
fmt.Println("Sending Pings %v\n", i)
pings <- struct{}{}
time.Sleep(...)
}
close(pings)
}
func ponger (pings, pongs chan struct{}){
i:= 0
for range pings {
fmt.Printf("got ping %v, sending pong %v\n", i, i)
pongs <- struct{}{}
i++
}
close(pongs)
}
The output of the above will look something like this:
sending ping 0
got ping 0, sending pong 0
got pong 0
sending ping 1
got ping 1, sending pong 1
got pong 1
…
pings done
pongs done
The closing chain here is beautiful:
- pinger finishes -> closes pings
- ponger sees pings closed -> closes pongs
- Main goroutine sees pongs closed -> the for range exits
Each goroutine is responsible for closing the channel it owns (sends to). This cascading closure is a clean, idiomatic Go pattern.
The Complete Mental Model
Let me give you one final analogy that ties everything that we learnt above together.
Imagine a post office:

Quick References
//create an unbuffered channel
ch:= make(chan int)
//create a buffered channel (capacity 5)
ch:= make(chan int, 5)
//send a value
ch <- 42
//rcieve a value
val := <- ch
//rcv a value with close check
val, ok:= <-ch
if !ok { /*channel was closed*/}
//close a channel (sender's job)
close(ch)
//select btw multiple channels
select{
case v:= <-ch1:
//handle ch1
case v:= <-ch2:
//handle ch2
default:
//do this if nothing is ready
}
//send only channel type
func onlySend( ch chan<- int){
ch<-1
}
//rcv only channel type
func onlyRcv(ch <-chan int){
v:= <-ch;
}
Common Pitfals to Avoid
- Deadlock: Everyone’s waiting for everyone else
- Sending to a closed channel: causes a panic
- Forgetting to close: for ranges hangs forever
Summary
Here’s what we talked about:
- Goroutines are Go’s way of doing work concurrently (Lightweight, cheap, powerful).
- Channels are the pipes goroutines use to communicate safely.
- Unbuffered Channels sync goroutines directly (Both sides read/write must be ready)
- Buffered Channels act as a queue (the sender doesn’t have to wait immedietly)
- Closing Channels signals “no more data” (the reciever can detect this)
- for range on a channel reads until it’s closed (clean and idiomatic)
- select lets you wait on multiple channels at once (handles whichever is ready)
- select with default makes you select non-blocking
- Directional Channels restrict a channel to send-ony or recieve-only for safety.
Go’s channel model follows a simple philosophy (from Tony Hoare’s CSP):
Don’t communicate by sharing memory; share memory by communicating.
Instead of having goroutines fight over shared variables (which leads to race conditions, mutexes and bugs), they talk through channels. The channel handles all the synchronisation for you.
Once you think in channels; like cooks, pass-through windows, post offices, and ping pong tables; concurrent Go code stops feeling scary and starts feeling natural.
Now go build something concurrent. You’ve got the tools.
Found this helpful? Follow me for more Go deep-dives. All the code examples in this article come from real exercises on [Boot.dev](https://boot.dev) highly recommend it if you’re learning Go.