Closing Channels — Find the Bug¶
Each section presents broken code. Read it carefully, predict the symptom, then read the explanation and fix. The bugs are real — every one has been found in production Go code.
How to use this file¶
- Read the snippet.
- State the symptom: panic, deadlock, leak, wrong output, race?
- Identify the root cause.
- Sketch a fix.
- Read the explanation and compare.
Bug 1: classic forgotten close¶
func numbers() <-chan int {
ch := make(chan int)
go func() {
for i := 0; i < 10; i++ {
ch <- i
}
}()
return ch
}
func main() {
for v := range numbers() {
fmt.Println(v)
}
}
Symptom. Prints 0..9, then deadlock: fatal error: all goroutines are asleep.
Root cause. The producer goroutine sends 10 values and exits without closing. The consumer's for range blocks waiting for the 11th value that will never arrive.
Fix.
The defer close(ch) runs on goroutine exit, regardless of how exit happens.
Bug 2: closing in the wrong goroutine¶
func numbers() <-chan int {
ch := make(chan int)
go func() {
for i := 0; i < 10; i++ {
ch <- i
}
}()
close(ch) // outside the goroutine
return ch
}
Symptom. Panic: send on closed channel.
Root cause. close(ch) runs immediately in the parent, before the goroutine has sent anything. The first send then panics. Even if it didn't, the consumer would see a closed channel immediately.
Fix. Move close inside the goroutine:
Bug 3: multi-sender, each closes¶
func main() {
ch := make(chan int, 100)
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
defer close(ch) // each sender closes
for j := 0; j < 10; j++ {
ch <- id*10 + j
}
}(i)
}
wg.Wait()
for v := range ch {
fmt.Println(v)
}
}
Symptom. Panic: close of closed channel. Sometimes also send on closed channel.
Root cause. Five senders each call close(ch). The first close succeeds; the second panics. If a sender is mid-loop when another closes, that send also panics.
Fix. Single closer pattern:
Remove defer close(ch) from each sender. One synchronising closer.
Bug 4: closing inside the receiver¶
func main() {
ch := make(chan int)
go func() {
for i := 0; i < 5; i++ {
ch <- i
}
}()
received := 0
for v := range ch {
fmt.Println(v)
received++
if received == 3 {
close(ch) // receiver closes
}
}
}
Symptom. Panic: send on closed channel on the producer's next send.
Root cause. The receiver closes the channel while the producer is still sending. The next send panics.
Fix. Use a done channel to signal the producer to stop:
ch := make(chan int)
done := make(chan struct{})
go func() {
defer close(ch)
for i := 0; i < 5; i++ {
select {
case <-done:
return
case ch <- i:
}
}
}()
received := 0
for v := range ch {
fmt.Println(v)
received++
if received == 3 {
close(done)
// drain remaining
for range ch {
}
break
}
}
The receiver signals via close(done); the producer observes and closes ch cleanly.
Bug 5: double-close in defer chain¶
func work(ch chan int) error {
defer close(ch)
if err := step1(); err != nil {
close(ch) // panic on second defer
return err
}
return step2()
}
Symptom. When step1 errors, panic: close of closed channel.
Root cause. Two close(ch) calls: the explicit one in the if-branch, then the deferred one when work returns. Both run.
Fix. Use defer close(ch) once; remove the explicit close:
func work(ch chan int) error {
defer close(ch)
if err := step1(); err != nil {
return err
}
return step2()
}
Or guard with sync.Once:
var once sync.Once
closeCh := func() { once.Do(func() { close(ch) }) }
defer closeCh()
if err := step1(); err != nil {
closeCh()
return err
}
(The defer-only version is cleaner.)
Bug 6: nil-close after struct reset¶
type Worker struct {
ch chan int
}
func (w *Worker) Start() {
w.ch = make(chan int)
// ...
}
func (w *Worker) Stop() {
close(w.ch) // panic if Start never called
}
func main() {
w := &Worker{}
w.Stop() // panic: close of nil channel
}
Symptom. Panic: close of nil channel.
Root cause. w.ch is the zero value (nil) because Start was not called. Closing a nil channel panics.
Fix. Check before closing:
Or always initialise in the constructor:
Bug 7: race between close and send¶
func main() {
ch := make(chan int)
go func() {
for i := 0; i < 100; i++ {
ch <- i // may panic
}
}()
go func() {
time.Sleep(10 * time.Millisecond)
close(ch)
}()
for v := range ch {
_ = v
}
}
Symptom. Sometimes panics: send on closed channel. Sometimes runs OK.
Root cause. The closer races with the sender. After close, the sender's next iteration panics.
Fix. Use a done channel for cancellation; close the data channel only after the sender exits.
ch := make(chan int)
done := make(chan struct{})
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
defer close(ch)
for i := 0; i < 100; i++ {
select {
case <-done:
return
case ch <- i:
}
}
}()
go func() {
time.Sleep(10 * time.Millisecond)
close(done)
}()
for v := range ch {
_ = v
}
wg.Wait()
Bug 8: close to signal error¶
func work() (<-chan int, <-chan error) {
values := make(chan int)
errs := make(chan error)
go func() {
defer close(values)
for i := 0; i < 10; i++ {
if i == 5 {
errs <- errors.New("boom")
close(errs)
return // signal end via not-sending more
}
values <- i
}
}()
return values, errs
}
func main() {
values, errs := work()
for v := range values {
fmt.Println(v)
}
for e := range errs {
fmt.Println(e)
}
}
Symptom. Deadlock or panic, depending on timing.
Root cause. Multiple bugs:
errsis unbuffered; the goroutine blocks onerrs <- errbecause the main goroutine is readingvalues, noterrs.- The error path also closes
errs. If the main then rangeserrs, it sees the closed channel and exits. - After receiving the error and breaking out, the values channel may not be drained.
Fix. Use a single channel with a Result struct, or buffer the error channel:
type Result struct {
V int
Err error
}
func work() <-chan Result {
out := make(chan Result)
go func() {
defer close(out)
for i := 0; i < 10; i++ {
if i == 5 {
out <- Result{Err: errors.New("boom")}
return
}
out <- Result{V: i}
}
}()
return out
}
Bug 9: range on never-closed input¶
func filter(in <-chan int, pred func(int) bool) <-chan int {
out := make(chan int)
go func() {
for v := range in {
if pred(v) {
out <- v
}
}
// forgot close(out)
}()
return out
}
func main() {
in := make(chan int, 3)
in <- 1
in <- 2
in <- 3
close(in)
for v := range filter(in, func(x int) bool { return x > 1 }) {
fmt.Println(v)
}
}
Symptom. Prints 2 3, then deadlock.
Root cause. filter's goroutine exits cleanly after its input closes, but out is never closed. The consumer's for range out blocks.
Fix. defer close(out):
Bug 10: send to closed in select¶
func main() {
ch := make(chan int)
close(ch)
select {
case ch <- 1: // panics; selectable case
default:
fmt.Println("default")
}
}
Symptom. Panic: send on closed channel.
Root cause. The send case on a closed channel is selectable (it doesn't block), so select picks it. The send then panics.
Fix. Don't send to a channel that might be closed. If unavoidable, structure differently — e.g., observe a done channel:
This works only if ch is open; if ch is closed but done is not, the panic recurs. The root fix is: don't have a code path that sends to a possibly-closed channel.
Bug 11: goroutine leak with select on done¶
func worker(done <-chan struct{}, jobs <-chan Job) {
for {
select {
case j := <-jobs:
process(j) // long, no cancellation check
case <-done:
return
}
}
}
Symptom. After close(done), the worker may still be in process(j) for a long time. The done close has no effect during processing.
Root cause. select is checked once per loop iteration. While process runs, the worker is not in select. The done signal is not observed mid-process.
Fix. Pass done to process:
func process(done <-chan struct{}, j Job) {
for /* internal loop */ {
select {
case <-done:
return
default:
}
// do one step
}
}
Or use context.Context consistently throughout.
Bug 12: double for range on same channel¶
func main() {
ch := make(chan int)
go func() {
defer close(ch)
for i := 0; i < 5; i++ {
ch <- i
}
}()
for v := range ch {
fmt.Println("first:", v)
}
for v := range ch {
fmt.Println("second:", v) // never executes
}
}
Symptom. First loop prints 0..4; second loop runs zero times.
Root cause. After the first loop, the channel is closed and drained. The second for range on a closed-drained channel runs zero iterations (immediately exits).
Fix. This is usually a logic error: re-using a closed channel. Create a new channel or restructure.
Bug 13: closing a channel returned by a library¶
func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
done := ctx.Done()
close(done) // compile error
}
Symptom. Compile error: cannot close receive-only channel.
Root cause. ctx.Done() returns <-chan struct{}; cannot close.
Fix. Cancel the context, not the channel:
Bug 14: WaitGroup race with close¶
func main() {
ch := make(chan int)
var wg sync.WaitGroup
for i := 0; i < 3; i++ {
go func(id int) {
wg.Add(1) // wrong: inside goroutine
defer wg.Done()
ch <- id
}(i)
}
go func() {
wg.Wait()
close(ch)
}()
for v := range ch {
fmt.Println(v)
}
}
Symptom. Sometimes deadlock. Sometimes close happens before all sends.
Root cause. wg.Add(1) is inside the goroutine. The closer goroutine may run wg.Wait() before any of the sender goroutines have reached Add. With counter at 0, Wait returns immediately; close(ch) runs; senders then send to a closed channel.
Fix. Move wg.Add(1) to the parent before go:
Bug 15: closing during pipeline cancellation, no drain¶
func main() {
ctx, cancel := context.WithCancel(context.Background())
out := make(chan int)
go func() {
defer close(out)
for i := 0; ; i++ {
select {
case <-ctx.Done():
return
case out <- i:
}
}
}()
for i := 0; i < 5; i++ {
<-out
}
cancel()
// forgot to drain out
time.Sleep(100 * time.Millisecond)
fmt.Println("done")
}
Symptom. Goroutine leak (the producer is blocked on out <- i, but out has no reader since we stopped consuming).
Root cause. After cancel(), the producer's select has two ready cases: ctx.Done() and out <- i (if reader is parked — but no reader, so this case blocks). The producer picks ctx.Done() only if its case is selectable when select runs. If the send is in progress (rendezvous half-done), the select may not retry.
Reality. Actually, with an unbuffered channel and no reader, the send case is never ready, so the select picks ctx.Done(). The bug here is more subtle: if the buffer were non-empty or if the timing were different, you could see leaks.
Best practice. Always drain:
A defensive drain ensures any in-flight send completes, allowing the producer to observe cancellation cleanly.
Bug 16: closing channel held by struct after struct discarded¶
type Pub struct {
ch chan int
}
func main() {
p := &Pub{ch: make(chan int)}
sub := p.ch
p = nil // p no longer references the channel
runtime.GC()
close(sub) // OK; sub still references the channel
_, ok := <-sub
fmt.Println(ok) // false
}
Symptom. No bug! Channels are reference types. As long as one variable references the channel, it lives. Closing it via that reference is legal.
Lesson. This is a non-bug but a good clarification. The channel does not "belong" to the struct in a way that destroying the struct destroys the channel.
Bug 17: send on closed inside recover¶
func main() {
ch := make(chan int, 1)
close(ch)
defer func() {
if r := recover(); r != nil {
fmt.Println("recovered:", r)
ch <- 1 // panics inside recover
}
}()
ch <- 1
}
Symptom. Recovered from first panic, then a new panic propagates: send on closed channel.
Root cause. The recover does not "unclose" the channel. The second send panics again; the second panic is not caught by another defer.
Fix. Don't retry on closed channel. Restructure the code to avoid sends after close.
Bug 18: closing nil channel via direction conversion¶
func close_it(c chan<- int) {
close(c)
}
func main() {
var ch chan int
close_it(ch) // panic: close of nil channel
}
Symptom. Panic: close of nil channel.
Root cause. Conversion from chan int to chan<- int preserves the nil value. Closing a nil channel panics regardless of direction.
Fix. Initialise with make before passing.
Bug 19: select with no default + done case¶
func consume(done <-chan struct{}, work <-chan int) {
for {
select {
case v := <-work:
fmt.Println(v)
case <-done:
return
}
}
}
func main() {
done := make(chan struct{})
work := make(chan int)
go consume(done, work)
close(done)
close(work) // unnecessary; consume already returned
time.Sleep(10 * time.Millisecond)
}
Symptom. No panic here, but close(work) is unnecessary. Worse, if consume were modified to be a multi-sender on work, this close-after-done sequence could race.
Lesson. Closing channels that already have an exit path is redundant. Audit close calls; remove unnecessary ones.
Bug 20: close inside loop body, not defer¶
func process(ch chan int) {
if shouldClose() {
close(ch)
return
}
for v := range source {
ch <- v
}
}
Symptom. When shouldClose is false, the function returns without closing. Consumers leak.
Root cause. No defer close(ch). The close only runs on one code path.
Fix. defer close(ch) at the top:
func process(ch chan int) {
defer close(ch)
if shouldClose() {
return
}
for v := range source {
ch <- v
}
}
Bug 21: close inside select default¶
for {
select {
case v := <-in:
process(v)
default:
close(out) // panics on next iteration's default
}
}
Symptom. Infinite loop; close called every iteration when in is empty; panics after first close.
Root cause. default runs whenever no other case is ready. Inside default, the close fires; on the next iteration, default fires again and tries to close already-closed channel.
Fix. Restructure so close happens once, on a deliberate condition.
Bug 22: closing a "results" channel before workers finish¶
func main() {
jobs := make(chan int, 10)
results := make(chan int, 10)
for i := 0; i < 3; i++ {
go func() {
for j := range jobs {
results <- j * 2
}
}()
}
for i := 0; i < 5; i++ {
jobs <- i
}
close(jobs)
close(results) // wrong: workers still writing
for r := range results {
fmt.Println(r)
}
}
Symptom. Sometimes panics: send on closed channel from a worker.
Root cause. close(results) happens immediately, while workers are still processing. Their results <- j*2 after the close panics.
Fix. Use the synchronising-closer pattern:
var wg sync.WaitGroup
for i := 0; i < 3; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := range jobs {
results <- j * 2
}
}()
}
for i := 0; i < 5; i++ {
jobs <- i
}
close(jobs)
go func() {
wg.Wait()
close(results)
}()
for r := range results {
fmt.Println(r)
}
Bug 23: orphaned goroutine after panic recovery¶
func main() {
ch := make(chan int)
defer func() {
if r := recover(); r != nil {
fmt.Println("recovered:", r)
}
}()
go func() {
defer close(ch)
panic("oops")
}()
for v := range ch {
fmt.Println(v)
}
}
Symptom. Process terminates with "panic: oops" (not recovered).
Root cause. recover is in main's defer. The panic happens in a different goroutine. Cross-goroutine recover doesn't work.
Fix. Recover inside the panicking goroutine:
go func() {
defer close(ch)
defer func() {
if r := recover(); r != nil {
fmt.Println("recovered:", r)
}
}()
panic("oops")
}()
The deferred close(ch) still runs, so the consumer's for range exits cleanly.
Bug 24: closing a channel as a flag¶
type Service struct {
ch chan int
}
func (s *Service) IsClosed() bool {
select {
case <-s.ch:
return true
default:
return false
}
}
Symptom. If the channel has a value, IsClosed consumes it and returns true falsely. Race condition.
Root cause. <-s.ch succeeds for any receive — including a real value. The "is closed" check is conflated with "is empty + closed."
Fix. Use comma-ok:
func (s *Service) IsClosed() bool {
select {
case _, ok := <-s.ch:
return !ok
default:
return false
}
}
But this is still racy — the answer is stale. The right pattern is to track closed-ness explicitly in a separate flag protected by a mutex, or to never query.
Bug 25: closing in cleanup function before goroutine exits¶
func setup() (chan int, func()) {
ch := make(chan int)
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
for v := range ch {
process(v)
}
}()
cleanup := func() {
close(ch) // OK: signals worker to exit
wg.Wait()
}
return ch, cleanup
}
Symptom. Works. But: if the caller closes ch themselves (forgetting cleanup does it), cleanup panics.
Lesson. Document the close contract clearly. If cleanup closes the channel, the caller MUST NOT close it. Better: use a done channel instead of closing the data channel, so the data channel close is unambiguous.
Bug 26: pipeline that doesn't propagate cancellation¶
func stage(in <-chan int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for v := range in {
out <- v * 2 // no ctx check
}
}()
return out
}
Symptom. Cancellation does not stop mid-stream. If in keeps sending and out reader has stopped, the goroutine blocks forever.
Fix. Add context:
func stage(ctx context.Context, in <-chan int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for v := range in {
select {
case <-ctx.Done():
return
case out <- v * 2:
}
}
}()
return out
}
Summary of Bug Patterns¶
| Pattern | Frequency | Symptom |
|---|---|---|
| Forgot to close | very high | range deadlock, leak |
| Multi-sender, each closes | high | double-close panic |
| Receiver closes | medium | send-on-closed panic |
| Double-close in error paths | high | close-of-closed panic |
| Nil-close | low | nil-close panic |
| Race between close and send | medium | intermittent panic |
| Close to signal error | medium | ambiguous semantics |
| Send to closed in select | low | panic on selectable case |
WaitGroup Add inside goroutine | medium | wait returns too early |
| Missing context in pipeline send | high | hang on cancellation |
| Cross-goroutine recover | medium | recovery missed, process dies |
The vast majority of bugs cluster around ownership unclear and cancellation not propagated. Discipline around defer close in producers, single-closer for multi-sender, and select on ctx.Done() in every send path prevents most of them.
Run every concurrent test with go test -race. The race detector catches the unsynchronised-close-vs-send class of bugs.