Understanding Go Channels with Examples

A channel is simply a pipe between goroutines. One goroutine puts data in, another takes it out. It's Go's built-in way to share data safely without breaking things. In other words, think of Go channels as a conveyor belt used to move data between different parts of your code (goroutines).

In our previous blog, Concurrency in Go using Goroutine and WaitGroup, we learned how we can use goroutines to run tasks concurrently, and how WaitGroups let us wait for them to finish. But there was still a missing piece, what if one goroutine needs to send data to another?

We can think of a channel like a pipe. One goroutine puts something in one end, and another goroutine picks it up from the other end.

goroutine A  -->  [ channel ]  -->  goroutine B

This is how goroutines talk to each other in Go, instead of sharing a variable and hoping nothing breaks, they pass data through a channel safely.


Creating a Channel

Syntax:

// this is unbuffered channel, will talk about types later
ch := make(chan string) 

This creates a channel that can carry string values. We can make channels for any other types:int, bool, struct, anything.


Sending and Receiving

The <- operator is used for both sending and receiving:

ch <- "hello"   // send "hello" into the channel
msg := <-ch     // receive from the channel, store in msg

Just remember: the arrow always points in the direction the data flows.


Simple Example

Let's say we have a goroutine that does some work and wants to report back when it's done:

package main

import "fmt"

func doWork(ch chan string) {
    fmt.Println("Working...")
    ch <- "work done!" // send result into the channel
}

func main() {
    ch := make(chan string) // creates a channel

    go doWork(ch) // goroutine

    result := <-ch // wait here until doWork sends something
    fmt.Println(result)
}

After running the program, output looks like this:

Working...
work done!

Notice we didn't need a WaitGroup here. The result := <-ch line already blocks until doWork sends a value, so the program naturally waits. Whenever we run a go program main function always runs as goroutine. In this example, doWork goroutine pass data to main, another goroutine.


Types of Channels: Unbuffered vs Buffered Channels

Unbuffered (default)

An unbuffered channel has no storage. The sender blocks until someone is ready to receive, and the receiver blocks until someone sends. They have to meet at the same time, like a direct handoff. If a goroutine tries to send a value and no one is waiting to receive it, the sender will pause (block) until a receiver arrives.

An unbuffered channel is like two people passing a heavy box. The person giving the box cannot let go until the other person is there to take it.

  • Synchronous: Both the sender and receiver must be ready at the exact same time.

  • Blocking: If the sender arrives first, they wait. If the receiver arrives first, they wait.

sender  -->  blocks until receiver is ready  -->  receiver

Syntax:

ch := make(chan string)

Example:

package main

import "fmt"

func main() {
    // Created without a capacity
    ch := make(chan string)

    go func() {
        ch <- "Hello!" // SENDER: Stays here until someone reads
        fmt.Println("Sent!")
    }()

    msg := <-ch // RECEIVER: Stays here until someone sends
    fmt.Println("Received:", msg)
}

Buffered

A buffered channel has a small storage (a queue). The sender can put values in without waiting, as long as the buffer isn't full. The receiver can pick them up whenever it's ready.

A buffered channel is like a mailbox with a specific number of slots. The sender can drop off letters even if the receiver isn't home—as long as the mailbox isn't full.

  • Asynchronous (mostly): Buffered channels are asynchronous (mostly) because the sender and receiver only block when the buffer is full or empty, respectively.

  • Capacity: You define a size (e.g., 3). The sender can send 3 items without anyone reading them.

sender  -->  [ value1 | value2 | value3 ]  -->  receiver (reads when ready)

Syntax:

ch := make(chan string, 3) // can hold up to 3 values

Example:

package main

import "fmt"

func main() {
    // Created with a capacity of 2
    ch := make(chan string, 2)

    ch <- "First"  // Doesn't block
    ch <- "Second" // Doesn't block

    // ch <- "Third" // This WOULD block because the buffer is full!

    fmt.Println(<-ch)
    fmt.Println(<-ch)
}

Closing a Channel

Closing a channel is optional in Go, but in many situations it helps receivers know that no more values are coming.

When you're done sending, you can close a channel.

Syntax:

close(ch)

Example:

package main

import "fmt"

func doWork(ch chan string) {
    // Ensure the channel closes when this function finishes
    defer close(ch) 

    fmt.Println("Working...")
    ch <- "work done!"
}

func main() {
    ch := make(chan string)

    go doWork(ch)

    // main waits here. 
    result := <-ch 
    fmt.Println(result)
}

However, there are three specific reasons why you must close a channel:

  1. To signal "range" loop to stop

    This is the most common reason. If you are using a for range loop to read from a channel, the loop will keep waiting forever unless the channel is closed.
    Go

    package main 
    
    import "fmt"
    
    func main() {
        ch := make(chan int, 3)
        ch <- 1
        ch <- 2
        close(ch) // If you remove this line, you will get error: deadlock
    
        for val := range ch {
            fmt.Println(val)
        }
    }
    
  2. To communicate "no more work"

    Closing a channel is a way to broadcast a signal to one or many goroutines. When a channel is closed, any receiver currently waiting on it will immediately receive the zero value of the channel type and a boolean false.
    Go

    val, ok := <-ch
    if !ok {
        fmt.Println("Channel is closed and empty!")
    }
    
  3. To prevent goroutine leaks

    If a goroutine is waiting on a channel that will never receive more data and is never closed, it may remain blocked for the lifetime of the program. This is called a goroutine leak.

    Closing the channel signals that no more values will be sent, allowing waiting receivers to exit cleanly instead of blocking indefinitely.

Important: Only sender should close a channel, not the receiver. And, sending to a closed channel causes a panic.