How do channels work

Channels are typed conduits for safe communication between goroutines using send and receive operations.

The pipe between goroutines

You write a function that fetches a user profile from a database. It takes two seconds. You write another function that pulls their order history. It takes three seconds. Running them one after another wastes five seconds of user time. You want them to run at the same time and hand their results to a main function that merges the data. In Go, you do not share memory to make that happen. You communicate by sharing memory instead. The tool for that communication is the channel.

What a channel actually is

A channel is a typed pipe. You create it, hand one end to a sender and the other to a receiver, and values flow through it. Think of a conveyor belt in a factory. Workers on one side place finished parts onto the belt. Workers on the other side grab parts as they arrive. If the belt is full, the sender waits. If the belt is empty, the receiver waits. The belt enforces the order and pace. In Go, that belt is a chan type. It guarantees that exactly one goroutine sends a value and exactly one goroutine receives it. No locks. No race conditions. Just a direct handoff.

Channels are first-class values. You can pass them to functions, store them in structs, and return them from methods. The type system tracks what flows through them. A chan int only carries integers. A chan *http.Request only carries request pointers. The compiler enforces this boundary at compile time. If you try to send a string into an integer channel, the compiler rejects the program with cannot use "hello" (untyped string constant) as int value in send. This strict typing prevents silent data corruption in concurrent code.

Channels coordinate. They do not replace locks for complex state. Pick the right tool for the data flow.

The minimal example

package main

import "fmt"

// SendNumber places a value into the channel and exits.
func SendNumber(ch chan<- int) {
	// The <- operator pushes the value into the channel.
	// This call blocks until a receiver is ready.
	ch <- 42
}

func main() {
	// make creates the underlying buffer and synchronization primitives.
	// An unbuffered channel has a capacity of zero.
	ch := make(chan int)

	// Launch the sender in a separate goroutine.
	// Without this, the program would deadlock immediately.
	go SendNumber(ch)

	// The <- operator pulls a value out of the channel.
	// This call blocks until a sender provides a value.
	value := <-ch
	fmt.Println("Received:", value)
}

Step by step execution

The program starts in main. It calls make(chan int), which allocates a small structure in memory. That structure holds a queue for values, a lock for thread safety, and two wait queues: one for blocked senders and one for blocked receivers. Because we did not pass a capacity to make, the channel is unbuffered. An unbuffered channel requires both sides to be ready at the exact same moment.

The go SendNumber(ch) line spins up a new goroutine. The scheduler puts it on the run queue and immediately continues to the next line in main. The next line is value := <-ch. The main goroutine tries to read from an empty channel. It cannot proceed. The runtime parks the main goroutine and adds it to the channel receiver wait queue.

The scheduler eventually picks up the SendNumber goroutine. It executes ch <- 42. The runtime checks the channel. It sees a parked receiver. It copies the integer 42 directly from the sender stack to the receiver stack, wakes up the main goroutine, and marks the send as complete. Both goroutines unblock simultaneously. The main goroutine prints the result and exits. The SendNumber goroutine finishes and cleans up.

This synchronous handoff is the default behavior. It forces coordination without explicit locks. The sender cannot fire and forget. The receiver cannot poll and spin. They meet in the middle.

Unbuffered channels synchronize. Buffered channels decouple. Choose based on your pacing needs.

Channels in a real service

Let us move to something that looks like production code. You have a list of URLs to scrape. You want to process them concurrently but limit the concurrency to three workers. You also want to collect the results in order. A buffered channel plus a sync.WaitGroup handles this cleanly.

package main

import (
	"context"
	"fmt"
	"sync"
)

// FetchResult holds the output of a single HTTP request.
type FetchResult struct {
	URL   string
	Size  int
	Error error
}

// Worker reads URLs from the jobs channel and writes results to the results channel.
// It respects the WaitGroup to signal completion to the main goroutine.
func Worker(ctx context.Context, id int, jobs <-chan string, results chan<- FetchResult, wg *sync.WaitGroup) {
	// Defer ensures the WaitGroup counter decrements even if a panic occurs.
	defer wg.Done()

	// Range over the jobs channel automatically stops when the channel closes.
	for url := range jobs {
		// Check context cancellation before starting work.
		// This prevents workers from starting new tasks during shutdown.
		if ctx.Err() != nil {
			return
		}

		// Simulate network I/O. In real code, this would be an HTTP call.
		size := len(url) * 100
		results <- FetchResult{URL: url, Size: size}
	}
}

func main() {
	// Buffered channels decouple senders from receivers.
	// A buffer of 10 allows workers to keep running without blocking.
	jobs := make(chan string, 10)
	results := make(chan FetchResult, 10)

	var wg sync.WaitGroup

	// Spin up three concurrent workers.
	for i := 1; i <= 3; i++ {
		wg.Add(1)
		// Context always goes first. It carries cancellation signals.
		go Worker(context.Background(), i, jobs, results, &wg)
	}

	// Feed work into the pipeline.
	go func() {
		for i := 0; i < 10; i++ {
			jobs <- fmt.Sprintf("https://example.com/page/%d", i)
		}
		// Close signals that no more jobs will arrive.
		// Workers will finish their current loop iteration and exit.
		close(jobs)
	}()

	// Wait for all workers to finish, then close the results channel.
	go func() {
		wg.Wait()
		close(results)
	}()

	// Collect results until the channel is closed and empty.
	for res := range results {
		fmt.Printf("Fetched %s: %d bytes\n", res.URL, res.Size)
	}
}

This pattern separates work distribution from result collection. The jobs channel acts as a bounded queue. The results channel acts as a bounded output pipe. The sync.WaitGroup tracks how many workers are still active.

Notice the channel direction annotations in the Worker signature: <-chan string and chan<- FetchResult. These are read-only and write-only channel types. They restrict what the function can do with the channel. A function that only sends cannot accidentally receive. A function that only receives cannot accidentally close the channel. The compiler enforces this at compile time. If you try to send on a read-only channel, the compiler rejects the program with invalid operation: cannot send to receive-only type chan string. This directional typing is a Go convention that keeps concurrent code safe and self-documenting.

The close(jobs) call is crucial. Closing a channel does not clear it. It simply flips a flag inside the channel structure. Any subsequent send operation on a closed channel triggers a runtime panic. The compiler cannot catch this because the channel might be closed by a different goroutine at an unpredictable time. The runtime checks the flag, sees it is closed, and crashes the program with panic: send on closed channel. Always close a channel from the sender side, never from the receiver side. The receiver should use range or the ok idiom to detect closure gracefully.

Channels coordinate. They do not replace locks for complex state. Pick the right tool for the data flow.

Where things go wrong

Channels are simple, but they introduce specific failure modes. The most common is the goroutine leak. If a goroutine blocks on a channel send and no one ever receives, that goroutine stays parked forever. It holds onto memory, file descriptors, or database connections. The program eventually runs out of resources. The fix is always the same: ensure every channel has a cancellation path. Use context.Context to signal shutdown, or close the channel when the work is done.

Another trap is assuming channels are thread-safe queues you can share freely. They are, but they are not meant to replace sync.Mutex for protecting complex state. If you need to update a map while checking a condition, a mutex is the right tool. Channels are for moving values between execution contexts. Using a channel to simulate a lock adds unnecessary scheduling overhead and makes the code harder to read.

You will also see the select statement in production code. select lets a goroutine wait on multiple channel operations at once. It picks one that is ready to proceed. If none are ready, it blocks. If you need a timeout, you add a case that receives from a timer channel. The compiler enforces that every case in a select is a channel operation. You cannot put arbitrary logic inside a select case. The statement is purely for routing concurrent signals.

A small convention pays off here: gofmt formats channel operations with consistent spacing. You will see ch <- val and val := <-ch. Do not fight the formatter. Let the tool decide indentation and spacing. Most editors run gofmt on save. Trust the output.

The worst goroutine bug is the one that never logs. Always trace your channel lifecycles.

When to reach for a channel

Use an unbuffered channel when you need strict synchronization between two goroutines and want to guarantee the sender waits for the receiver. Use a buffered channel when you want to decouple producers from consumers and allow a burst of work without blocking. Use a directional channel type when passing channels to functions to restrict their capabilities and prevent accidental sends or closes. Use a sync.Mutex when you need to protect shared mutable state that involves read-modify-write cycles. Use plain function returns when you only need to pass a single value back to the caller without concurrency.

Channels coordinate. They do not replace locks for complex state. Pick the right tool for the data flow.

Where to go next