What Are Channels in Go and How Do They Work

Channels are typed conduits that allow goroutines to communicate by sending and receiving values, acting as the primary synchronization mechanism in Go.

The handshake problem

You have two goroutines. One calculates a result. The other needs that result to continue. If the second goroutine runs first, it reads uninitialized memory or a stale value. If the first goroutine runs too slowly, the second one spins uselessly waiting for data. You could use a mutex and a shared variable, but that turns your code into a traffic cop managing who gets to read and write. Go offers a different path. You pass the data directly through a typed conduit. The conduit handles the timing. The sender waits until the receiver is ready. The receiver waits until the sender arrives. No locks. No shared state. Just a direct handoff.

How channels actually work

A channel is a typed pipe built into the language runtime. You declare the type it carries, like chan int or chan *http.Request. The runtime allocates a small control structure on the heap that tracks the buffer, the send queue, and the receive queue. When you send a value, the runtime copies it into the channel. When you receive, it copies it out. The copy happens at the exact moment the send and receive meet. That meeting point is a synchronization barrier. Memory writes before the send are guaranteed to be visible to the goroutine that receives. You do not need to worry about CPU cache coherence or memory ordering. The channel enforces it.

Unbuffered channels enforce a strict handshake. The send blocks until a receive is ready. The receive blocks until a send arrives. They wake each other up simultaneously. The runtime scheduler parks the blocking goroutine and resumes the waiting one. This happens in user space, not through operating system syscalls. That is why channels feel fast.

Buffered channels add a waiting room. You specify a capacity when you create the channel. Sends fill the buffer and return immediately until it is full. Receives drain the buffer and return immediately until it is empty. Once the buffer hits its limits, the behavior falls back to the strict handshake. The buffer does not change the type safety or the memory guarantees. It only changes when the blocking occurs.

Channels are coordination primitives, not data structures. Treat them as timing mechanisms first and storage second.

The minimal handshake

Here is the simplest possible exchange. One goroutine produces three integers. The main goroutine consumes them. The channel forces them to step in lockstep.

package main

import "fmt"

func main() {
    // Unbuffered channel forces a direct handoff
    ch := make(chan int)

    // Spawn producer in background
    go func() {
        for i := 1; i <= 3; i++ {
            fmt.Printf("Sending %d\n", i)
            // Blocks here until main() pulls the value
            ch <- i
        }
        // Signal completion so range loop can exit
        close(ch)
    }()

    // Drain channel until closed
    for val := range ch {
        fmt.Printf("Received %d\n", val)
    }
}

Watch the execution flow. The go func() starts running. It hits ch <- i. The runtime sees no receiver waiting. It parks the goroutine and yields the CPU. The main goroutine reaches range ch. It sees a sender waiting. It wakes both goroutines. The integer moves from the producer to the receiver. The main loop prints it. The producer continues to the next iteration and blocks again. This repeats until the loop finishes. The producer calls close(ch). The range loop detects the closed channel and exits cleanly.

The range keyword on a channel is a convenience wrapper. It repeatedly receives until the channel closes. If you never close the channel, the range loop hangs forever. The runtime will eventually panic with all goroutines are asleep - deadlock! because the main goroutine is stuck waiting for more data that will never arrive.

Always close channels from the sender side. Closing a channel is a broadcast signal. It tells every receiver that no more values are coming. Receivers can still read buffered values after a close. They just get the zero value and a false flag once the buffer drains. The Go community follows a strict convention here: the creator of the channel closes it, or the goroutine that owns the data flow closes it. Never close a channel from the receiving side. That is a recipe for panics.

When the pipe needs a waiting room

Strict handshakes work great for tight coupling. They fall apart when producer and consumer run at different speeds. Imagine a web server accepting requests. The handler spawns a worker to process a database query. If the worker uses an unbuffered channel, the handler blocks until the worker finishes. That ties up the HTTP connection. A buffered channel decouples them. The handler drops the request into the buffer and returns immediately. The worker pulls at its own pace.

package main

import (
    "fmt"
    "time"
)

func main() {
    // Buffer holds 2 items to absorb bursts
    tasks := make(chan string, 2)

    // Simulate fast producer
    for i := 1; i <= 4; i++ {
        fmt.Printf("Submitting task %d\n", i)
        // First two return instantly. Third blocks.
        tasks <- fmt.Sprintf("job-%d", i)
    }

    // Simulate slow consumer
    time.Sleep(100 * time.Millisecond)
    for t := range tasks {
        fmt.Printf("Processing %s\n", t)
    }
}

The producer fires off two tasks. They sit in the buffer. The third send blocks because the buffer is full. The producer pauses. The consumer wakes up, drains the buffer, and the third send finally completes. The fourth task follows. The producer finishes and the program ends. Wait. The program ends immediately after the loop. The consumer never gets to process the remaining items. That is a classic leak. The main goroutine exits, killing the background worker. In production code, you would use a sync.WaitGroup or a done channel to keep the process alive until the pipeline drains.

Buffered channels trade memory for latency. Every slot in the buffer reserves space on the heap. Size your buffer based on your burst tolerance, not your total workload. A buffer of ten is usually enough to smooth out network jitter. A buffer of ten thousand is a memory leak waiting to happen. The runtime does not automatically shrink the buffer when you are done. It stays allocated until the channel is garbage collected.

What breaks when you get it wrong

Channels are simple, but the runtime enforces strict rules. Violate them and the program stops.

Sending to a closed channel panics immediately. The runtime prints panic: send on closed channel. You cannot reopen a channel. Once it is closed, it is dead. If you need to send more data, create a new channel.

Receiving from a closed channel does not panic. It returns the zero value for the type. If you use the comma-ok idiom, val, ok := <-ch, the ok flag becomes false. This is how range knows when to stop. If you ignore the ok flag and keep reading, you get silent zero values. That turns into subtle data corruption. The comma-ok pattern is the standard way to handle graceful shutdowns in long-running services. Check the flag, break the loop, and clean up resources.

Deadlocks happen when every goroutine is waiting on a channel and no one can proceed. The runtime detects this state and aborts the program with fatal error: all goroutines are asleep - deadlock!. Common causes include sending to an unbuffered channel with no receiver, or a circular dependency where goroutine A waits for B, and B waits for A. The compiler cannot catch these. They only appear at runtime. Trace the data flow on paper before you write the code. Draw arrows for every send and receive. If the arrows form a circle, you have a deadlock waiting to happen.

Directional channels exist to enforce flow. You can declare a parameter as chan<- int (send-only) or <-chan int (receive-only). The compiler rejects any mismatched operation. If you try to receive from a send-only channel, you get invalid operation: receive from send-only channel. This convention keeps large pipelines readable. Producers only see the send side. Consumers only see the receive side. The full channel type lives in the factory function that wires them together. It is a small detail that pays off when your codebase grows.

Channels are coordination, not storage. Size them for timing, not for capacity.

Picking the right synchronization tool

Go gives you several ways to coordinate goroutines. Choose based on the data flow, not the novelty.

Use an unbuffered channel when two goroutines must exchange a value at a specific point. Use a buffered channel when you need to decouple producer and consumer speeds or absorb short bursts of work. Use a sync.WaitGroup when you need to wait for multiple independent tasks to finish without passing data between them. Use a sync.Mutex when multiple goroutines need to read and write the same complex data structure. Use a direct function call when you do not need concurrency at all. The simplest thing that works is usually the right thing.

Where to go next