Go Channels Explained: Understanding <-chan for Receive-Only Channels

Go Channels Explained: Understanding <-chan for Receive-Only Channels

Channels are Go's mechanism for goroutines to communicate. They're typed conduits that let you send and receive values between concurrent operations safely.

ch := make(chan int)

// Send
ch <- 42

// Receive
value := <-ch

By default, channels are bidirectional. You can send to them and receive from them. But Go lets you restrict channel direction using type constraints, which makes concurrent code clearer and prevents mistakes.

Channel Direction Syntax

Three channel types exist in Go:

chan T        // Bidirectional: can send and receive
chan<- T      // Send-only: can only send
<-chan T      // Receive-only: can only receive

The arrow indicates the direction of data flow. For <-chan T, the arrow points away from chan, meaning data flows out (receive-only). For chan<- T, the arrow points toward chan, meaning data flows in (send-only).

This might feel backward at first. The arrow follows the data, not the operation. A receive-only channel is written <-chan because values flow from the channel to your code.

Why Restrict Channel Direction?

Type safety. If a function should only read from a channel, accepting a <-chan parameter enforces that at compile time:

func consume(ch <-chan int) {
    for value := range ch {
        fmt.Println(value)
    }
}

Now consume can't accidentally send to the channel. If you try to write ch <- 42 inside consume, the compiler rejects it.

The same applies to send-only channels:

func produce(ch chan<- int) {
    for i := 0; i < 10; i++ {
        ch <- i
    }
    close(ch)
}

This function can send values and close the channel but can't receive. The type system enforces the contract.

Implicit Conversion

Go converts bidirectional channels to directional channels automatically when you pass them to functions:

ch := make(chan int)
go produce(ch)  // chan int converts to chan<- int
consume(ch)     // chan int converts to <-chan int

You create a bidirectional channel, but the type system restricts how each function can use it. This is useful for structuring concurrent code—create channels in one place, restrict their use in functions that don't need full access.

The conversion only works one way. You can't convert a receive-only channel to a send-only channel or back to a bidirectional channel.

Typical Pattern: Producer-Consumer

A common pattern involves one goroutine producing values and another consuming them:

func producer(ch chan<- int) {
    for i := 0; i < 5; i++ {
        ch <- i
        time.Sleep(time.Millisecond * 100)
    }
    close(ch)
}

func consumer(ch <-chan int) {
    for value := range ch {
        fmt.Println("Received:", value)
    }
}

func main() {
    ch := make(chan int)
    go producer(ch)
    consumer(ch)
}

The producer sends values and closes the channel when done. The consumer receives until the channel is closed. The directional types make the intent explicit.

Closing Channels

Only the sender should close a channel. Closing a receive-only channel doesn't make sense—you're not the one sending, so you shouldn't decide when sending is done.

The close function works on send-only and bidirectional channels but not on receive-only channels. The compiler enforces this.

Receivers can check if a channel is closed:

value, ok := <-ch
if !ok {
    fmt.Println("Channel closed")
}

Or use a range loop, which stops automatically when the channel closes:

for value := range ch {
    fmt.Println(value)
}

This is cleaner and avoids manual checking.

Buffered Channels

Channels can have a buffer:

ch := make(chan int, 10)  // Buffered channel with capacity 10

A buffered channel doesn't block the sender until the buffer is full. This decouples the producer and consumer slightly—the producer can send several values before the consumer reads any.

Buffered channels are still subject to direction restrictions. A receive-only buffered channel works the same as an unbuffered one—you just can't send to it.

Select Statement

The select statement lets you wait on multiple channels:

select {
case value := <-ch1:
    fmt.Println("Received from ch1:", value)
case value := <-ch2:
    fmt.Println("Received from ch2:", value)
case ch3 <- 42:
    fmt.Println("Sent to ch3")
default:
    fmt.Println("No channel ready")
}

Directional channels work with select. You can't receive from a send-only channel in a case, and you can't send to a receive-only channel.

Common Mistakes

Forgetting to close channels: If a producer never closes the channel, a consumer using range will block forever waiting for more values. This causes deadlocks.

Closing from the receiver: Only the sender knows when it's done sending. Receivers shouldn't close channels. If multiple senders exist, coordination is needed—typically one goroutine closes the channel after all senders finish.

Sending on a closed channel: This panics. Once a channel is closed, you can't send to it. Make sure senders know when to stop.

When to Use Directional Channels

Use directional channels in function signatures when:

  • The function's role is clear—producer or consumer, not both
  • You want compile-time guarantees about how a channel is used
  • You're writing library code where API clarity matters

Don't bother with directional channels for:

  • Channels used only within a single function or small scope
  • Channels that need bidirectional access legitimately

It's a tool for clarity and safety, not a rule to apply everywhere.

Channels vs Other Synchronization

Go has mutexes and other synchronization primitives. Channels aren't always the right choice. Use channels when you're actually communicating data between goroutines. Use mutexes when you're protecting shared state without transferring ownership.

The Go proverb is "Don't communicate by sharing memory; share memory by communicating." Channels fit the second approach—ownership of data transfers from one goroutine to another through the channel.

Further Reading

The Go specification's section on channel types defines the behavior of directional channels precisely.

Effective Go's chapter on concurrency covers channels, goroutines, and common patterns in detail.

For deeper patterns, see the Go blog on concurrency patterns, which demonstrates pipelines, fan-out, and cancellation using channels.

Channels are central to Go's concurrency model.

Wear the code

Product mockup

<-chan Developer T-Shirt (Go Edition — Dark Mode)

£25.00

View product
Product mockup

<-chan Developer T-Shirt (Go Edition — Light Mode)

£25.00

View product

0 comments

Leave a comment

Please note, comments need to be approved before they are published.