Skip to content
gohacks.dev
Concurrency

Panic Send on Closed Channel and How to Avoid It in Go

Fix panic send on closed channel in Go: reproduce it, find the racing close, and apply ownership fixes for one or many senders, with context to stop them.

7 min read
Summarize in AI
Flat illustration of a buffered channel between a sender and a receiver.

panic: send on closed channel is one of the clearest runtime errors Go has, and one of the most frustrating, because the line it points at, the send, is correct. The bug is somewhere else: some other goroutine closed the channel while this one still meant to send on it. The fix is in the design of who closes, not in the send.

Tested with Go 1.25.6, macOS on Apple Silicon (arm64)

This page reproduces the panic in the three ways it usually arises, explains how to read a trace, and gives the pattern that prevents each one. It follows closing channels, which covers the rules in general, and it belongs to the concurrency material. Everything ran on Go 1.25.6.

The smallest reproduction

main.goGo
package main

import "fmt"

func main() {
	defer func() { fmt.Println("recovered:", recover()) }()

	ch := make(chan int, 1)
	close(ch)
	ch <- 1
}
Text
recovered: send on closed channel

The send panics immediately with send on closed channel. In a real program the close and the send are in different goroutines, and the order is decided by the scheduler, so the panic appears sometimes. That intermittency is the characteristic symptom: it fails once in a thousand runs, or only under load, or only at shutdown.

Reading the trace

An unrecovered panic prints the goroutine that panicked, which is the one doing the send:

Text
panic: send on closed channel

goroutine 8 [running]:
main.worker(...)
	/app/main.go:21
created by main.main in goroutine 1
	/app/main.go:34

The frame in your code shows where the send happened. The goroutine that closed the channel is gone from the picture. To find it, search for close( on that channel variable. The questions to ask: how many places can reach that close, and could any of them run while a sender is still active? The answer is usually that the close lives in the receiver, in a shutdown handler or in one of several senders.

Cause 1: the receiver closes

The receiver finishes early and closes the channel "to clean up". The sender, still running, sends once more:

main.goGo
package main

import (
	"fmt"
	"time"
)

func main() {
	ch := make(chan int)
	done := make(chan string)

	go func() {
		defer func() { done <- fmt.Sprint("sender: ", recover()) }()
		for i := 0; ; i++ {
			ch <- i
			time.Sleep(5 * time.Millisecond)
		}
	}()

	for v := range ch {
		if v == 2 {
			close(ch) // wrong: the receiver does not own the channel
			break
		}
	}
	fmt.Println(<-done)
}
Text
sender: send on closed channel

The receiver reads values until it has enough, then closes. The sender, which has no idea, wakes from its sleep and sends on the closed channel. The recover in the sender goroutine is only so the demonstration prints; in the real program the process would exit. The fix is the one rule from closing channels: the receiver never closes. If the receiver wants the sender to stop, it asks through a context (see the fix below) and the sender closes its own channel.

Cause 2: several senders, one of which closes

Each sender decides it is done and closes:

main.goGo
package main

import (
	"fmt"
	"sync"
)

func main() {
	ch := make(chan int)
	var wg sync.WaitGroup
	var mu sync.Mutex
	var panics []string

	go func() {
		for range ch {
		}
	}()

	for i := range 3 {
		wg.Go(func() {
			defer func() {
				if r := recover(); r != nil {
					mu.Lock()
					panics = append(panics, fmt.Sprint(r))
					mu.Unlock()
				}
			}()
			for j := 0; j < 1000; j++ {
				ch <- j
			}
			if i == 0 {
				close(ch) // "I'm finished" — but the others are not
			}
		})
	}
	wg.Wait()
	fmt.Println("senders that panicked:", len(panics) > 0)
}
Text
senders that panicked: true

Sender 0 finishes and closes the channel. Senders 1 and 2 are still sending, and at least one of them panics at its next send. Whether it happens depends on the timing, so run it a few times if you try it. Closing from one of several senders is wrong because none of them knows the state of the others. The fix is a coordinator that closes after all senders are done, below.

Cause 3: a late send during shutdown

Shutdown code closes channels to stop workers, while producers elsewhere are still handing work in. An HTTP handler that pushes onto a job queue, and a shutdown routine that closes the queue, race at the moment of shutdown: a request that arrives just after the close panics in its send. The same applies to event buses that close subscriber channels on unsubscribe while a publisher is mid-send (event buses). Here the fix is to stop the producers first, or to guard the channel with a mutex and a closed flag inside the type that owns it, so that a send after close returns an error instead of panicking.

The fixes

One sender closes

Where one goroutine sends, it closes with defer, and nobody else touches close:

Go
go func() {
	defer close(out)
	for _, v := range values {
		out <- v
	}
}()

A coordinator closes for several senders

main.goGo
package main

import (
	"fmt"
	"sort"
	"sync"
)

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

	for i := range 3 {
		wg.Go(func() {
			for j := 0; j < 2; j++ {
				ch <- i*10 + j
			}
		})
	}

	go func() {
		wg.Wait()
		close(ch)
	}()

	var got []int
	for v := range ch {
		got = append(got, v)
	}
	sort.Ints(got)
	fmt.Println(got)
}
Text
[0 1 10 11 20 21]

The senders never close. A separate goroutine waits for all of them and then closes, so no send can follow the close (fan-out and fan-in).

Stop senders with a context

To stop a sender from outside, the sender watches a context and exits, closing its own channel on the way out:

main.goGo
package main

import (
	"context"
	"fmt"
)

func produce(ctx context.Context) <-chan int {
	out := make(chan int)
	go func() {
		defer close(out)
		for i := 0; ; i++ {
			select {
			case out <- i:
			case <-ctx.Done():
				return
			}
		}
	}()
	return out
}

func main() {
	ctx, cancel := context.WithCancel(context.Background())
	var last int
	for v := range produce(ctx) {
		last = v
		if v == 4 {
			cancel()
			break
		}
	}
	fmt.Println("stopped after", last)
}
Text
stopped after 4

The receiver cancels and stops reading. The producer's select takes the Done case, returns, and the deferred close runs. No send ever lands on a closed channel because the same goroutine that sends is the one that closes, after it has stopped sending.

Guard a channel you cannot restructure

Sometimes a type has to offer "send an event" and "close" as separate operations, as when a subscriber list owns the channels. The safe version holds a mutex around both and checks a flag:

main.goGo
package main

import (
	"errors"
	"fmt"
	"sync"
)

var ErrClosed = errors.New("queue closed")

type Queue struct {
	mu     sync.Mutex
	closed bool
	ch     chan int
}

func NewQueue() *Queue { return &Queue{ch: make(chan int, 8)} }

func (q *Queue) Send(v int) error {
	q.mu.Lock()
	defer q.mu.Unlock()
	if q.closed {
		return ErrClosed
	}
	q.ch <- v // buffered, so this does not hold the lock for long
	return nil
}

func (q *Queue) Close() {
	q.mu.Lock()
	defer q.mu.Unlock()
	if !q.closed {
		q.closed = true
		close(q.ch)
	}
}

func main() {
	q := NewQueue()
	fmt.Println(q.Send(1))
	q.Close()
	q.Close() // safe
	fmt.Println(q.Send(2))
}
Text
<nil>
queue closed

A send after close now returns ErrClosed and the caller decides what to do. This costs a mutex and a buffered channel, because a blocking send while holding the lock would block Close too. Use it when you must expose a close method; prefer the ownership patterns above when you can.

What not to do

Don't wrap sends in recover to ignore the panic; it leaves the race in place and hides the lost message. Don't try to detect a closed channel with a non-blocking receive before sending: between the check and the send it can close. Don't add time.Sleep before the close to "let senders finish". Do run your tests with -race, which does not flag channel closes as data races but often changes timing enough to expose the intermittent ones.

Checklist

  1. Find every close(ch) for the channel in the stack trace.
  2. Is it called by a receiver? Move it to the sender.
  3. Is it called by one of several senders? Move it to a coordinator after wg.Wait().
  4. Is it called at shutdown while producers still run? Stop the producers first, or guard with a closed flag.
  5. Does the sender have a way to stop that is not a close? Add a context.

The language definition of the behavior is in the specification's section on send statements, which states that sending on a closed channel causes a run-time panic, and the Go blog's Pipelines and cancellation shows the multi-sender close pattern in full. The sibling error for the mirror situation, a receive that waits forever, is the all-goroutines-asleep deadlock, and the panics for the other misuses of close are collected in the list of runtime panics.

Also inBasics
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.