Recommended resources
The freely available resources this lesson will direct you to.
Hello. Last time, you used a context to tell concurrent work when to stop, and a WaitGroup to confirm that goroutines had finished. A channel has another lifecycle question: who may declare that no more values will arrive? In this lesson, you’ll assign that responsibility to the producing side, first with one sender and then with several. Allow about 40 minutes to read, run, and trace the examples.
What closing a channel promises
Closing a channel does not remove it or interrupt a send safely. It makes a specific promise to receivers: no further values will be sent on this channel. A receiver can use that promise to finish a for range loop. If the channel is buffered, receivers obtain any remaining buffered values before the loop ends.
Watch the relevant parts of Understanding Channels in Go with Code Examples, from Code & Learn, for a quick demonstration of receiving values and detecting completion.
Understanding Channels in Go with Code Examples
Watch how a receiver finishes reading a channel, then listen for the video's concise statement of producer and consumer responsibilities.
Watch range and closure, focusing on why the loop terminates only when the channel is closed. Then watch channel ownership for the distinction between the writer's and reader's roles.
For an individual receive, Go offers value, ok := <-ch. After all sent values have been received, a closed channel yields the element type’s zero value with ok == false. A value actually sent—even a zero value—comes with ok == true. Use for range when you simply want every value until completion; use ok when you need to handle closure in a single receive.

Closing twice panics, and sending after closure panics. That makes closure an ownership decision, not something each goroutine should attempt whenever it happens to finish. Receiving from a channel gives you no authority to close it.
One sender: one clear closer
Read the Channel Closing Principle in How to Gracefully Close Channels, from Go 101. It gives a useful default rule and explains why a more general guarantee is harder to maintain.
How to Gracefully Close Channels
Read the channel-closing principle to establish a default ownership rule before looking at code with several senders.
In “The Channel Closing Principle,” begin with the default rule. Continue through the qualification, stopping before “Solutions Which Close Channels Rudely.” Focus on the guarantee required before any goroutine calls close.
The default design is straightforward: a sole sender closes its outbound channel after its final send. The receiver reads until closure; it does not try to infer whether the sender is “probably finished.”
func produce(nums ...int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for _, n := range nums {
out <- n
}
}()
return out
}
The return type, <-chan int, gives callers a receive-only view. They can consume results, but they cannot send to or close out through that view. The producing goroutine retains the channel and is the only sender, so its deferred close(out) runs after the loop finishes. This also works when nums is empty: the goroutine sends nothing, then closes the channel so a receiver does not wait indefinitely.
Closure is not mandatory for every channel. It is needed here because callers are expected to use closure to recognize the end of the stream. Think of it as a completion signal, not a general-purpose cleanup call.
Several senders: separate sending from closing
Now suppose several goroutines send into one output channel. None of them can close it merely because its own work is done: another sender may still be using it. The producing side needs a coordinator that can establish that all sends have finished and then close the channel exactly once.
Read the two relevant examples in Go Concurrency Patterns: Pipelines and cancellation, from the Go blog. The gen and sq stages show sole-sender ownership; merge shows how that rule changes for a shared output.
Go Concurrency Patterns: Pipelines and cancellation
Study the single-sender stages and the fan-in coordinator. The printed code may be compact; concentrate on which goroutine performs each send, which goroutine closes each output, and what must finish before closure.
In “Squaring numbers,” read from the stage descriptions, including the gen and sq code immediately following their descriptions; stop before the main setup example. Then, in “Fan-out, fan-in,” read from the merge explanation and its WaitGroup outline. Stop at the “Stopping short” heading; early exit is the next lesson’s concern.
Here is a complete, runnable version of that ownership pattern. Save it as main.go and run go run main.go.
package main
import (
"fmt"
"sync"
)
func produce(nums ...int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for _, n := range nums {
out <- n
}
}()
return out
}
func merge(inputs ...<-chan int) <-chan int {
out := make(chan int)
var wg sync.WaitGroup
wg.Add(len(inputs))
for _, in := range inputs {
go func(in <-chan int) {
defer wg.Done()
for n := range in {
out <- n
}
}(in)
}
go func() {
wg.Wait()
close(out)
}()
return out
}
func main() {
results := merge(
produce(1, 2),
produce(3, 4),
)
for n := range results {
fmt.Println(n)
}
}
You should see 1, 2, 3, and 4 exactly once each. Their combined order is not guaranteed: each produce call preserves its own send order, but the two paths through merge can be scheduled differently.
Trace the ownership rather than the print order:
- Each
producegoroutine is the sole sender and closer of its own channel. - Each forwarding goroutine in
mergesends toout, but never closesout. mergeregisters all forwarding goroutines withwg.Addbefore starting the goroutine that callswg.Wait. Only after every forwarder has returned does that coordinator closeout.mainonly receives. Itsfor rangeends whenoutis closed after the last send.
The coordinator belongs to the producing side of out, even though it does not send values itself. It owns the proof that all senders have finished. This also handles merge() with no inputs: the WaitGroup has nothing to wait for, so the coordinator closes the output promptly.
Make the ownership rule explicit
When reviewing a channel-based design, identify its data senders, its single closer, and the condition that makes closure safe. For a sole sender, that condition is the end of its sending work. For multiple senders, it is confirmation that every sender has finished. Keep that decision in one place rather than asking each worker or the receiver to close “if necessary.”
The WaitGroup from the previous lessons supplies exactly the coordination needed in merge; it does not itself close anything. Likewise, the context from the previous lesson supplies a cancellation signal, not permission for a worker to close an unrelated results channel. In particular, workers receive from ctx.Done(); they do not close it.
One important limit remains. The runnable example assumes main consumes all results. If a receiver takes one result and leaves, a producer may remain blocked trying to send the next one, even though channel ownership and closure were designed correctly. Closing the output from the receiver would risk a panic rather than solve that problem. The next lesson addresses how a goroutine can stop when cancellation occurs while it might otherwise remain blocked.
Takeaways
Close a channel to tell receivers that no more values will be sent, not simply to “clean it up.” Give a sole sender responsibility for closing its outbound channel. When senders share an output, give a producing-side coordinator the sole closing responsibility after all senders finish. Receivers consume the resulting completion signal; they do not close the data channel on the senders’ behalf.