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.

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
package main
import "fmt"
func main() {
defer func() { fmt.Println("recovered:", recover()) }()
ch := make(chan int, 1)
close(ch)
ch <- 1
}recovered: send on closed channelThe 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:
panic: send on closed channel
goroutine 8 [running]:
main.worker(...)
/app/main.go:21
created by main.main in goroutine 1
/app/main.go:34The 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:
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)
}sender: send on closed channelThe 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:
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)
}senders that panicked: trueSender 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 func() {
defer close(out)
for _, v := range values {
out <- v
}
}()A coordinator closes for several senders
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)
}[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:
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)
}stopped after 4The 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:
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))
}<nil>
queue closedA 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
- Find every
close(ch)for the channel in the stack trace. - Is it called by a receiver? Move it to the sender.
- Is it called by one of several senders? Move it to a coordinator after
wg.Wait(). - Is it called at shutdown while producers still run? Stop the producers first, or guard with a closed flag.
- 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.


