Sunday, October 25, 2026
Why Do My Goroutines Leak When I Range Over A Channel?
Posted by

Introduction
Your goroutines leak when they range over a channel because a range loop over a channel ends only when that channel is closed and drained. If no code closes the channel, the loop has no reason to end, so the goroutine waits forever. This article explains what a range loop actually waits for, who is responsible for closing a channel, why a range loop cannot be cancelled on its own, and a complete pipeline that exits cleanly in the normal case and on cancellation.
Before You Start
- Goroutine: a lightweight thread of execution started with
go. A goroutine runs until its function returns. The runtime does not stop a goroutine for you, and a blocked goroutine is not garbage collected. - Channel: a typed conduit that carries values between goroutines. A send blocks until a receiver is ready, for an unbuffered channel, or until the buffer has space, for a buffered channel. A receive blocks until a value is ready or the channel is closed and drained.
- Range over a channel: a loop of the form
for v := range ch. It receives values until the channel is closed and every buffered value has been received, then it exits. It does not exit on a timeout, on cancellation, or because the rest of the program moved on. - Close: the built in function
close(ch), which marks a channel as closed after the last sent value. Receives after that point drain buffered values, then return the zero value without blocking. - Leak: a goroutine that stays blocked forever, holding its stack and any values its stack references, for the life of the process. A leak is not a crash, and it is not always visible at once. It grows as the leaking pattern repeats.
- Context: a value from the
contextpackage that carries cancellation. When a context is cancelled, itsDonechannel closes, and code that selects onDonecan stop.
Prerequisites
- Go 1.20 or later, because the article uses
context,sync, and channel patterns that are stable in that version. - A terminal, and a text editor.
- No third party packages are required for the complete example.
go version
Requires: Go 1.20 or later. Verified on 2026-10-25 against the official sources in References.
Project Layout
channel-leak/
├── go.mod # module declaration, Go version
├── main.go # pipeline stages and main function
└── main_test.go # completion, cancellation, and closed channel tests
- go.mod: declares the module and the minimum Go version.
- main.go: holds the pipeline stages, so the close and cancellation rules can be read in order.
- main_test.go: holds tests that prove the pipeline finishes, that it stops on cancellation, and that a closed channel behaves as documented.
How A Range Over A Channel Ends
A range loop over a channel is a receive loop with a hidden exit condition. It keeps receiving, and it exits only when two things are both true: the channel is closed, and every value sent before the close has been received. The loop does not check anything else between receives.
for v := range ch {
fmt.Println(v)
}
If the channel is never closed, the loop never reaches its exit condition. It blocks on the next receive, forever, even if the sender has finished, even if the sender returned, and even if the rest of the program no longer cares about the values. That blocked loop is the leaked goroutine, when the loop runs in a goroutine, or a hung main function, when it runs in main.
A closed channel does not discard buffered values. Receives drain the buffer first, in order, and only then does the range loop exit. That is why closing early is safe for values already sent, and why closing late is the whole problem: the receiver cannot know the stream is finished until the close arrives.
The two value receive form shows the same rule without a loop:
v, ok := <-ch
if !ok {
fmt.Println("channel is closed and drained")
}
When ok is false, the channel is closed and drained, and v is the zero value for the element type. A range loop uses this rule internally. It does not use a separate signal, a timeout, or a count of values.
Who Closes A Channel
Only the sender closes a channel, and only once. The built in close documentation states this rule directly: it should be executed only by the sender, never by the receiver. The reason is ownership. The sender knows when no more values will arrive. The receiver does not. A receiver that closes a channel can close it while the sender is still sending, and a send on a closed channel panics.
Two close rules follow from that ownership rule, and both are panics if broken:
- Closing twice panics: a second
closeon the same channel panics, because the channel is already closed. - Sending on a closed channel panics: a sender that sends after the close panics, because the close promised that no more values would arrive.
The usual safe form is a deferred close in the sender, so every return path closes the channel exactly once:
func generate(jobs []Job) <-chan Job {
out := make(chan Job)
go func() {
defer close(out)
for _, job := range jobs {
out <- job
}
}()
return out
}
defer close(out) runs when the goroutine function returns, whether it returns normally or early. That guarantee is what makes the receiver's range loop safe to write. Without it, every early return in the sender is a possible leak in the receiver.
When several goroutines send on one channel, no single sender can close it, because one sender might close while another is still sending. The fix is to separate sending from closing: use a sync.WaitGroup to wait for every sender, then close the channel from one place, usually a separate goroutine:
var wg sync.WaitGroup
for _, job := range jobs {
wg.Add(1)
go func(job Job) {
defer wg.Done()
out <- process(job)
}(job)
}
go func() {
wg.Wait()
close(out)
}()
The closing goroutine is not a sender. It only waits and closes, once, after every sender is done. That pattern is the standard fan in shape, and it is the only safe way to close a channel with many senders.
Why A Range Loop Cannot Be Cancelled On Its Own
A range loop has no cancellation input. It receives from one channel, and it exits only on close. If the producer stops sending but never closes, the range loop cannot hear about cancellation, a timeout, or a shutdown signal, because it is not listening to anything except the channel it ranges over.
The fix is to stop using a plain range loop when early exit is possible, and to receive with a select that also watches cancellation:
for {
select {
case job, ok := <-in:
if !ok {
return
}
fmt.Println(job)
case <-ctx.Done():
return
}
}
This loop has two exit paths. The ok check handles the normal close, and the ctx.Done case handles cancellation. When the context is cancelled, the loop returns even if the input channel is never closed. The function that owns the output channel should still close that output channel on return, with defer close(out), so the next stage also exits.
The same rule applies to sends. A send on an unbuffered channel blocks until a receiver is ready. If the receiver has already returned, the sender blocks forever, and that is a leak on the sending side, even though the receiver is gone. A cancellable send uses the same select shape:
select {
case out <- value:
case <-ctx.Done():
return
}
Cancellation does not unblock a plain channel operation. It only works when the operation is written as a select case alongside ctx.Done. Every channel operation inside code that can be cancelled needs a Done arm, on the send side and on the receive side. A Done arm on only one side leaves the other side able to block forever.
A buffered channel does not fix this leak. It only delays it. A producer can send up to the buffer size without a receiver, then it blocks on the next send, exactly as with an unbuffered channel. A buffer is a capacity choice for bursts, not a cancellation mechanism, and not a substitute for closing.
What A Leak Costs
A leaked goroutine costs memory and runtime resources for the life of the process, and heap references in its stack keep data from being garbage collected. That statement comes from the Go pipelines article, which calls this out directly: goroutines are not garbage collected, they must exit on their own.
One leaked goroutine is often invisible. The same leak inside a request handler, a job loop, or a pipeline stage repeats with every request, every job, and every pipeline run. Memory grows with the number of leaked goroutines, and the stacks keep request data, buffers, and channel references alive long after the work is done. A program that looks correct in a single run can therefore fail slowly in production, as leaked goroutines accumulate.
A leak is also hard to see from the outside, because the program does not crash and does not log an error. The blocked goroutine is doing nothing, and doing nothing is silent. Tests that check completion, with a timeout, are the reliable way to catch it, because a leaked pipeline does not finish, and a test that waits forever is itself a symptom.
Design Decisions And Tradeoffs
| Option | Use when | Cost |
|---|---|---|
| Plain range over a channel | The producer always closes, and early exit is not needed | No cancellation path, so a missing close leaks the loop |
| Select receive with ctx.Done | Early exit, timeout, or shutdown is possible | More code per loop, and every stage must follow the same rule |
| Sender closes, with defer close | One sender owns one channel | The sender must close exactly once, on every return path |
| WaitGroup plus a closing goroutine | Many senders share one channel | An extra goroutine, and the close must wait for every sender |
| Buffered channel | Bursts need decoupling between producer and consumer | Capacity must be chosen, and a full buffer blocks exactly like an unbuffered channel |
| Context cancellation | A pipeline, request, or job has a lifetime shorter than the stream | Every send and receive in the pipeline needs a Done arm, or cancellation is partial |
| Closing from the receiver | Never, for a channel the receiver does not own | Panics on send after close, and races with senders that are still running |
A design in this article has a deliberate compromise, stated openly. The complete example uses one pipeline stage, not a worker pool, so the close and cancellation rules can be read without the extra WaitGroup closing pattern. In a real worker pool, the jobs channel still closes from one place, the workers still range over it, and a separate goroutine still closes the results channel after every worker finishes. The single stage here is a teaching compromise, not a production layout. The close ownership rule itself is not compromised: the sender closes, once, and the receiver never does.
Complete Example
The complete example is a two stage pipeline. A generator sends jobs and closes its output channel. A squaring stage receives jobs, squares them, sends results, and closes its output channel. The main function ranges over the results, which ends when the squaring stage closes its output, which happens when the generator closes its input, or when the context is cancelled.
// go.mod
module channelleak
go 1.20
// main.go
package main
import (
"context"
"fmt"
)
type Job struct {
ID int
Value int
}
type Result struct {
JobID int
Squared int
}
func generate(ctx context.Context, jobs []Job) <-chan Job {
out := make(chan Job)
go func() {
defer close(out)
for _, job := range jobs {
select {
case out <- job:
case <-ctx.Done():
return
}
}
}()
return out
}
func square(ctx context.Context, in <-chan Job) <-chan Result {
out := make(chan Result)
go func() {
defer close(out)
for {
select {
case job, ok := <-in:
if !ok {
return
}
select {
case out <- Result{JobID: job.ID, Squared: job.Value * job.Value}:
case <-ctx.Done():
return
}
case <-ctx.Done():
return
}
}
}()
return out
}
func main() {
ctx := context.Background()
jobs := []Job{
{ID: 1, Value: 1},
{ID: 2, Value: 2},
{ID: 3, Value: 3},
{ID: 4, Value: 4},
{ID: 5, Value: 5},
}
for result := range square(ctx, generate(ctx, jobs)) {
fmt.Printf("job %d squared is %d\n", result.JobID, result.Squared)
}
}
Commands to run it, from the channel-leak folder:
go run .
Expected output:
job 1 squared is 1
job 2 squared is 4
job 3 squared is 9
job 4 squared is 16
job 5 squared is 25
Why this program exits, in order:
generatesends every job, then returns, and its deferred close closes the jobs channel.squarereceives jobs until the jobs channel is closed and drained, then returns, and its deferred close closes the results channel.mainranges over the results channel, which ends when the results channel closes, somainreturns and the program exits. No goroutine is left blocked, because every stage has a close or a cancellation path, and every channel has exactly one closer.
If the context were cancelled in the middle, the same exits would happen by a different path: generate would return on ctx.Done, closing the jobs channel, and square would return on ctx.Done, closing the results channel, even if the jobs channel were never closed by another path. That is why both stages select on ctx.Done for sends and for receives.
Testing Channels
Test a pipeline by completion, not by counting goroutines by eye. A pipeline that leaks does not finish, so a test with a timeout turns a silent leak into a failure.
// main_test.go
package main
import (
"context"
"testing"
"time"
)
func TestPipelineCompletes(t *testing.T) {
ctx := context.Background()
jobs := []Job{
{ID: 1, Value: 1},
{ID: 2, Value: 2},
{ID: 3, Value: 3},
{ID: 4, Value: 4},
{ID: 5, Value: 5},
}
want := []int{1, 4, 9, 16, 25}
var got []int
for result := range square(ctx, generate(ctx, jobs)) {
got = append(got, result.Squared)
}
if len(got) != len(want) {
t.Fatalf("got %d results, want %d", len(got), len(want))
}
for i, w := range want {
if got[i] != w {
t.Errorf("result %d = %d, want %d", i, got[i], w)
}
}
}
func TestPipelineCancels(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
jobs := make([]Job, 10000)
for i := range jobs {
jobs[i] = Job{ID: i, Value: i}
}
out := square(ctx, generate(ctx, jobs))
cancel()
done := make(chan struct{})
go func() {
for range out {
}
close(done)
}()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("pipeline did not stop after cancel")
}
}
func TestClosedChannelReceive(t *testing.T) {
ch := make(chan int)
close(ch)
v, ok := <-ch
if ok {
t.Errorf("receive from closed channel ok = true, want false")
}
if v != 0 {
t.Errorf("receive from closed channel v = %d, want 0", v)
}
}
The completion test checks the full contract: every job is squared, in order, and the range loop ends. The cancellation test checks the early exit path: after cancel, the results channel must close within two seconds, or the test fails instead of hanging forever. The closed channel test checks the documented receive rule directly, without a pipeline.
Commands to run the tests and the static check:
go vet ./...
go test ./...
Expected output for the tests is a pass for every test, including the cancellation test, because both stages select on ctx.Done. go vet reports suspicious constructs. It does not detect a leaked goroutine, so do not treat a clean vet run as proof that a pipeline exits. The completion and cancellation tests are the proof for this example.
Common Mistakes
- Never closing the channel: a producer that returns without closing leaves every range loop over its channel blocked forever. The fix is
defer close(out)in the sender, so every return path closes. - Closing from the receiver: a receiver that closes a channel it does not own can close while the sender is still sending, and the next send panics. The fix is to close only from the sender, and to use a WaitGroup closing goroutine when there are many senders.
- Closing twice: a second close panics, even if the first close was correct. The fix is one closer, in one place, usually a deferred close in the single sender, or a WaitGroup closing goroutine for many senders.
- Sending on a closed channel: a sender that sends after the close panics. The fix is to stop sending before closing, and to never close a channel that another goroutine may still send on.
- Ranging over a channel that has no closer: a range loop over a channel created by a library, a callback, or a long lived producer may have no code responsible for closing it. The fix is to find the owner and give it a close path, or to replace the range loop with a select receive that also watches cancellation.
- Using range for a cancellable loop: a plain range loop cannot hear cancellation, so a cancelled pipeline keeps ranging until the producer closes, which may never happen. The fix is the select receive form in the complete example, with a
ctx.Donecase and anokcheck. - Adding a buffer and calling it fixed: a buffered channel lets the producer send up to the buffer size without a receiver, then it blocks on the next send, exactly as an unbuffered channel does. The fix is a close or cancellation path, not a larger buffer.
- Waiting for workers that never return: a
WaitGroup.Waitcall blocks until every worker callsDone, and a worker blocked in a range loop never callsDone. The fix is to close the jobs channel first, or to cancel the workers first, and only then to wait. - Breaking early without stopping the producer: a consumer that breaks out of a range loop early leaves the producer blocked on its next send, if the channel is unbuffered, or blocked once the buffer fills, if it is buffered. The fix is to cancel the context before breaking, or to drain the channel, and to give every send a
ctx.Donearm. - Treating a zero value as a real value: a receive from a closed and drained channel returns the zero value without blocking, so a loop that ignores the second value can process endless zero values. The fix is the two value form,
v, ok := <-ch, and a range loop, which already applies that rule, instead of a manual receive loop that dropsok.
Production Notes
- Shutdown: every long lived goroutine needs an owner, a close path, or a cancellation path. A server shutdown should cancel the context first, then wait for workers, in that order. Waiting first, then cancelling, waits forever for workers that are blocked in a range loop.
- Observability: a leak shows up as a growing goroutine count under load, not as an error. If goroutine count grows with requests and never falls, look for a range loop, a send, or a receive with no close or cancellation path. A completion test with a timeout catches the same leak before production.
- Timeouts: a pipeline that can block forever should have a timeout or a cancellation context from its caller, not a hope that the producer will close. A context with a deadline, passed to every stage, turns an unbounded wait into a bounded one.
- Resource limits: a leaked goroutine holds its stack and its referenced data. In a loop, that cost grows with every iteration. A buffer does not bound that cost, because a blocked sender still holds its stack, and a blocked receiver still holds its channel.
- Deployment: the example needs Go 1.20 or later. Pin that version in
go.mod, rungo vet ./...andgo test ./...in continuous integration, and do not deploy a change that fails the completion or cancellation tests. A vet pass alone is not enough for a pipeline, because vet does not prove that a loop exits.
Decision Guide
- When one sender owns one channel, and the consumer reads everything: use a plain range loop, and close from the sender with
defer close(out). - When early exit, timeout, or shutdown is possible: use a select receive with a
ctx.Donecase and anokcheck, and give every send actx.Donearm as well. - When many senders share one channel: use a WaitGroup to wait for every sender, then close from one closing goroutine, and never close from a sender or a receiver.
- When a consumer stops early: cancel the context before breaking out of the loop, so producers blocked on a send can return, instead of leaking.
- When a burst needs decoupling: use a buffered channel with a documented capacity, and still close it from the sender. A buffer is not a close path.
- When a stage must not outlive its caller: pass a context into the stage, and make the stage return on
ctx.Done. A stage with no context input cannot be stopped from outside.
Conclusion
A range loop over a channel is not a loop that ends on its own. It ends when the channel closes and drains, and only the sender can make that happen safely. If no code closes the channel, the loop waits forever, and that waiting is the leak.
Summary Of Key Points
- Range ends on close: a range loop exits only when the channel is closed and drained, never on cancellation, timeout, or disinterest.
- Sender closes, once: close only from the sender, with a deferred close for one sender, or a WaitGroup closing goroutine for many senders.
- Range cannot be cancelled alone: a cancellable loop needs a select receive with a
ctx.Donecase and anokcheck. - Sends need a Done arm too: a producer blocked on a send leaks when the receiver leaves, unless the send selects on cancellation.
- Buffer is not a fix: a buffered channel delays a block, it does not remove the need for a close or a cancellation path.
- Test completion with a timeout: a pipeline that leaks does not finish, so a completion test and a cancellation test turn a silent leak into a failure.
Follow those rules, and a channel range becomes a clean stream: the sender closes when it is done, every stage can stop on cancellation, and no goroutine is left waiting for a value that will never arrive.
Related Posts
- Why Did My Closure Capture The Wrong Loop Variable?
- Defer In A Go Loop
- Go Panic/Recover Vs Returning An Error: When To Use Which
Learn more:
Additional Resources
- Go Blog: Go Concurrency Patterns: Pipelines and cancellation - stages close their outbound channels, receivers range until close, and a done channel stops stages that would otherwise block
- Go Documentation: The Go Programming Language Specification - the close operation, and the rules for sending and receiving on closed channels
- Go Packages: builtin package - the close function, with the sender only rule and the closed channel receive rule
- Go Packages: context package - cancellation, deadlines, and the Done channel
- Go Packages: sync package - WaitGroup for waiting on many senders before closing
- Go Documentation: Effective Go - channels, goroutines, and sharing by communicating
References
- Go Concurrency Patterns: Pipelines and cancellation. Go Blog. https://go.dev/blog/pipelines. Accessed 2026-10-25.
- The Go Programming Language Specification. Go team, Go Documentation. https://go.dev/ref/spec#Close. Accessed 2026-10-25.
- builtin package. Go team, Go Packages. https://pkg.go.dev/builtin#close. Accessed 2026-10-25.
- context package. Go team, Go Packages. https://pkg.go.dev/context. Accessed 2026-10-25.
- sync package. Go team, Go Packages. https://pkg.go.dev/sync. Accessed 2026-10-25.
- Effective Go. Go team, Go Documentation. https://go.dev/doc/effective_go#concurrency. Accessed 2026-10-25.