How to Send and Receive Values on a Channel

Send values to a Go channel using the assignment operator and receive them using the receive operator.

How to Send and Receive Values on a Channel

Picture a web scraper. One goroutine fetches URLs from a list. Another goroutine parses the HTML and extracts links. You can't just share a slice of strings between them. That's a race condition waiting to happen. You need a pipe. A channel is that pipe. It is the only safe way to pass data between goroutines without locks.

Sending and receiving uses the <- operator. The arrow points in the direction of the data flow. When you write ch <- 42, the arrow points to the channel, meaning data goes in. When you write val := <-ch, the arrow points away, meaning data comes out. This visual cue helps you read the code quickly.

Think of an unbuffered channel like passing a heavy box to a friend. You can't let go of the box until your friend grabs it. Your friend can't move forward until they have the box. You are synchronized at the moment of the handoff. A buffered channel is like a drop box with slots. You can put the box in and walk away, as long as there's an empty slot.

Channels synchronize. They don't just move data; they coordinate time.

The simplest interaction

Here's the minimal channel interaction: create an unbuffered channel, send from a goroutine, and receive in main.

package main

import "fmt"

func main() {
	// Unbuffered channel requires both sides to be ready.
	ch := make(chan int)

	// Start a goroutine to send the value.
	go func() {
		// Send blocks until main receives.
		ch <- 42
	}()

	// Receive blocks until a value arrives.
	val := <-ch
	fmt.Println(val)
}

When you run this, the main goroutine hits make and creates the channel. It starts the anonymous goroutine. That goroutine tries to send 42. There is no receiver yet, so the goroutine pauses. The main goroutine continues to val := <-ch. It sees a sender waiting. The runtime wakes up the sender, copies 42 into val, and both goroutines move on.

The send and receive happen at the same instant. This is the handshake. The runtime guarantees that the value is copied and both goroutines proceed only after the transfer completes.

Realistic pattern: fan-out and fan-in

Here's a realistic pattern: fan-out work to multiple goroutines and collect the results.

package main

import (
	"fmt"
	"time"
)

// fetcher simulates an I/O operation returning a result.
func fetcher(id int, out chan<- string) {
	// Simulate network latency.
	time.Sleep(100 * time.Millisecond)
	// Send result to the channel.
	out <- fmt.Sprintf("data-%d", id)
}

func main() {
	// Buffered channel allows two sends without blocking.
	results := make(chan string, 2)

	// Launch two fetchers concurrently.
	for i := 1; i <= 2; i++ {
		go fetcher(i, results)
	}

	// Collect exactly two results.
	for i := 0; i < 2; i++ {
		fmt.Println(<-results)
	}
}

The fetcher function takes a chan<- string. This is a send-only channel. Go lets you restrict channel direction. chan<- string means you can only send. This prevents accidental receives and makes the API self-documenting. The compiler rejects any attempt to read from out inside fetcher.

The main function creates a buffered channel with capacity 2. This allows both fetchers to send their results and return immediately, even if main hasn't started receiving yet. The buffer absorbs the burst. If the buffer were size 0, the fetchers would block until main received.

The output order depends on scheduling. One fetcher might finish slightly before the other. The channel preserves the order of sends, but the goroutines run concurrently.

Fan-out and fan-in is the backbone of Go concurrency. Split the work, merge the results.

Consuming streams with range

When you don't know how many values are coming, use range. It pulls values until the channel closes.

package main

import "fmt"

func main() {
	// Channel to stream values.
	ch := make(chan int)

	// Sender closes channel when done.
	go func() {
		for i := 0; i < 3; i++ {
			ch <- i
		}
		close(ch)
	}()

	// range iterates until the channel closes.
	for val := range ch {
		fmt.Println(val)
	}
}

The range loop blocks waiting for values. When the sender calls close(ch), the loop exits automatically. This is the standard way to consume a stream. The sender is responsible for closing the channel. Closing a channel signals "no more values". Never close a channel from the receiver side. Only the sender should close.

If you try to send on a closed channel, the program panics with panic: send on closed channel. This is a hard error. The runtime catches it immediately. Always ensure only the goroutine that produces values closes the channel.

Nil channels and directionality

A channel variable starts as nil. If you try to send or receive on a nil channel, the goroutine blocks forever. This is intentional. It lets you use nil as a placeholder in select statements to disable a case dynamically.

package main

import "fmt"

// producer takes a send-only channel.
func producer(out chan<- int) {
	// Only sending is allowed here.
	out <- 10
}

func main() {
	// Full channel created in main.
	ch := make(chan int)

	// Pass to producer.
	producer(ch)

	// Receive in main.
	fmt.Println(<-ch)
}

Channels are reference types. Passing a channel copies the reference, not the buffer. Multiple goroutines can hold the same channel variable and they all talk to the same pipe. This is different from slices or maps in subtle ways, but the rule is the same: channels are cheap to pass. You can pass them to functions, store them in structs, and return them without worrying about copying the underlying data.

Trust gofmt. The indentation in these examples follows the standard tool. Most editors run it on save. Don't argue about formatting; let the tool decide.

Pitfalls and errors

If you send without a receiver and no buffer, the program stops. The runtime detects that no progress is possible and panics with fatal error: all goroutines are asleep - deadlock!. This usually means you forgot to start a goroutine to receive, or you're trying to receive before sending in a way that blocks forever.

Goroutine leaks happen when the goroutine waits on a channel that never gets closed. Always have a cancellation path. If a goroutine is blocked on a receive, and the sender dies without closing the channel, that goroutine lives forever. Use context.Context to signal cancellation. Functions that take a context should respect cancellation and deadlines. context.Context always goes as the first parameter, conventionally named ctx.

The worst goroutine bug is the one that never logs. Deadlocks crash fast. Leaks hide until the server dies.

When to use channels

Use an unbuffered channel when you need strict synchronization between two goroutines and want to ensure the sender and receiver meet at the same moment.

Use a buffered channel when the producer is faster than the consumer and you want to allow the producer to continue without blocking for a short burst.

Use a sync.WaitGroup when you need to wait for a set of goroutines to finish but don't need to pass data back.

Use a context.Context when you need to cancel a group of goroutines or pass deadlines, not for passing result data.

Use a mutex when multiple goroutines need to read and write the same variable, and you don't have a clear producer-consumer flow.

Pick the tool that matches the data flow. Channels move values. Mutexes protect state.

Where to go next