Channel ownership, closing rules, buffered vs unbuffered, and the failure modes that cause deadlocks or panics in Go.

Go channels: send, receive, close, and range without surprises


Channels are how goroutines talk to each other in Go. Send a value in one goroutine, receive it in another. Simple enough. The tricky parts show up around closing, ranging, and buffering: panics from sending on a closed channel, goroutines stuck behind an unbuffered send, and consumers that wait forever because nobody ever signals that production is finished.

This post covers the rules that matter in day-to-day Go: when to close a channel, who should close it, and how to avoid the failure modes that make channel code feel mysterious.

Unbuffered vs buffered channels

An unbuffered channel has no capacity. A send blocks until another goroutine is ready to receive, and a receive blocks until someone sends. This creates a hard synchronization point between two goroutines.

A buffered channel has a capacity you specify at creation. Sends block only when the buffer is full. Receives block only when it’s empty.

package main

import "fmt"

func main() {
	// Unbuffered: sender blocks until receiver is ready
	unbuffered := make(chan int)

	go func() {
		unbuffered <- 42 // blocks here until main receives
	}()
	fmt.Println(<-unbuffered)

	// Buffered: sender doesn't block until buffer is full
	buffered := make(chan int, 3)
	buffered <- 1
	buffered <- 2
	buffered <- 3
	// buffered <- 4 would block here — buffer is full, no receiver

	fmt.Println(<-buffered) // 1
	fmt.Println(<-buffered) // 2
	fmt.Println(<-buffered) // 3
}

When should you use a buffered channel? Start with unbuffered unless you have a reason not to. It makes the synchronization explicit. Buffered channels earn their keep when a producer generates short bursts of work and you deliberately want some slack between production and consumption.

The Go language specification defines channel types and their directionality. Worth reading if you want the precise semantics straight from the source.

The rules of closing a channel

Closing a channel signals that no more values will be sent. The rules are few, but breaking them usually fails loudly:

  1. Only the sender should close. The goroutine that sends values owns the channel and decides when it’s done.
  2. Never close from the receiver side. If a receiver closes a channel that a sender is still writing to, the sender panics.
  3. Sending on a closed channel panics. Always. No exceptions.
  4. Receiving from a closed channel still drains any buffered values first. Once the channel is closed and empty, receives return the element type’s zero value immediately.
  5. Closing a channel that’s already closed panics. Don’t close twice.
package main

import "fmt"

func producer(ch chan<- int) {
	for i := 0; i < 5; i++ {
		ch <- i
	}
	close(ch) // sender closes — this is correct
}

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

	for val := range ch {
		fmt.Println(val)
	}
	// range exits when ch is closed and drained
}

Notice the chan<- int type on the producer parameter. That’s a send-only channel. The type system enforces that producer can send and close, but cannot receive. Using directional channel types makes ownership obvious at a glance.

If you have multiple producers sending into the same channel, none of them should close it individually. Use a sync.WaitGroup to wait for all producers to finish, then close the channel from a coordinating goroutine:

package main

import (
	"fmt"
	"sync"
)

func main() {
	ch := make(chan int)
	var wg sync.WaitGroup

	for i := 0; i < 3; i++ {
		wg.Add(1)
		go func(id int) {
			defer wg.Done()
			for j := 0; j < 3; j++ {
				ch <- id*10 + j
			}
		}(i)
	}

	// A separate goroutine waits for all producers and then closes
	go func() {
		wg.Wait()
		close(ch)
	}()

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

The thing to notice: the goroutine calling close(ch) is coordinating shutdown. It waits until all producers finish, so one producer cannot close the channel while another one is still trying to send.

If you want to understand defer more deeply (like the defer wg.Done() above), check out how to defer in Golang like a pro.

How range over a channel works

range on a channel reads values until the channel is closed and drained. If the channel is never closed, the loop waits for another value forever. In long-running programs, that often becomes a goroutine leak.

A receive on a closed, empty channel returns the zero value immediately. You can distinguish that from a real value by using the two-value receive form:

val, ok := <-ch
if !ok {
    // channel is closed and empty
}

range uses the same receive semantics. When the channel is closed and empty, the loop exits.

Common failure modes

These are the ways channels go wrong. Learn to spot them in code reviews.

Deadlock: no receiver for an unbuffered send

ch := make(chan int)
ch <- 1 // blocks forever — no goroutine is receiving

The Go runtime detects this if all goroutines are asleep and prints fatal error: all goroutines are asleep - deadlock!. But if other goroutines are still running (say, an HTTP server), the runtime won’t catch it. Your goroutine just hangs silently.

Panic: send on a closed channel

ch := make(chan int)
close(ch)
ch <- 1 // panic: send on closed channel

There is no safe pre-send “is this channel closed?” check that avoids races with other goroutines. The fix is structural: make ownership clear so the sender, or a coordinator for all senders, is the only code that closes the channel.

Goroutine leak: forgetting to close

If a producer sends a finite set of values and a consumer ranges over the channel, the producer has to close the channel when it is done. Otherwise the consumer receives all available values and then blocks waiting for the next one. In a server, that stuck goroutine can sit around for the lifetime of the process.

This comes up a lot with context cancellation. If you launch a goroutine that produces values, make sure it either closes the channel when done or respects context cancellation so the consumer can stop waiting.

Panic: closing a nil channel

var ch chan int
close(ch) // panic: close of nil channel

A nil channel blocks on both send and receive forever. Closing it panics. Always initialize channels with make.

Channel ownership checklist

When you create a channel, ask yourself:

  • Who sends? That goroutine (or a coordinating goroutine) is responsible for closing.
  • Who receives? The receiver should never close the channel.
  • Is there one producer or many? Multiple producers need a WaitGroup and a coordinator to close.
  • Will the channel always be drained? If not, your producer goroutines might block on sends forever. Use a select with context.Done() or a quit channel.

Follow these rules and most channel code becomes easier to reason about. For more on Go fundamentals like control flow, see the guide to switch statements in Go.

Where this gets tricky in practice

Go channels are a small API: make, <-, close, range. Four operations. The interactions between them are where the sharp edges live. Buffered channels decouple producers from consumers. Unbuffered channels synchronize them. Closing signals “no more values.” The sender, or a coordinator acting for all senders, owns the close.

Get the ownership right and channels are boring in the best way. Get it wrong, and you get panics, deadlocks, and leaks that only show up under real load.