The Error
Your Go program crashes with:
goroutine 1 [running]:
main.main()
/home/user/app/main.go:25 +0x68
goroutine 6 [running]:
main.worker()
/home/user/app/main.go:14 +0x44
panic: send on closed channel
A goroutine tried to push a value into a channel that was already closed. Reading from a closed channel is fine โ Go returns the zero value and moves on. Sending is different. Any send on a closed channel panics immediately, no exceptions.
Root Cause
Four situations reliably trigger this panic:
- One goroutine closes the channel while another is still sending to it.
- The channel gets closed twice (that also panics, but with
panic: close of closed channel). - A worker goroutine keeps running after the coordinator already called
close(ch). - A timeout or cancellation tears down a pipeline while producers are mid-send.
Minimal reproduction โ hard to miss:
package main
func main() {
ch := make(chan int)
close(ch) // channel closed here
ch <- 1 // panic: send on closed channel
}
Real codebases hit a subtler version. The close happens before the goroutines even start:
package main
import (
"fmt"
"sync"
)
func main() {
ch := make(chan int, 10)
var wg sync.WaitGroup
// Closed too early โ workers haven't launched yet
close(ch)
for i := 0; i < 5; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
ch <- id // panic: send on closed channel
}(i)
}
wg.Wait()
fmt.Println("done")
}
Quick Fix: Recover from the Panic (Not for Production)
Go has no built-in isClosed() check. You can wrap the send in a defer recover() to absorb the panic:
func safeSend(ch chan int, val int) (closed bool) {
defer func() {
if r := recover(); r != nil {
closed = true
}
}()
ch <- val
return false
}
It works. It's also a band-aid over the real problem: unclear channel ownership. Fix the design instead.
Permanent Fix: The Sender Owns the Close
Go's unwritten rule: only the sender closes a channel, never the receiver. Close it after every send is done, and only once.
Pattern 1: Single Producer
One goroutine owns the channel entirely. It sends, then closes. Clean and simple:
package main
import "fmt"
func produce(ch chan<- int) {
for i := 0; i < 5; i++ {
ch <- i
}
close(ch) // safe: only this goroutine sends
}
func main() {
ch := make(chan int)
go produce(ch)
for v := range ch {
fmt.Println(v)
}
}
Pattern 2: Multiple Producers โ Coordinator + sync.WaitGroup
Five goroutines sending to one channel? None of them should close it. Hand that job to a coordinator:
package main
import (
"fmt"
"sync"
)
func main() {
ch := make(chan int, 10)
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
ch <- id // safe: channel is still open
}(i)
}
// This goroutine waits for all 5 senders, then closes
go func() {
wg.Wait()
close(ch)
}()
for v := range ch {
fmt.Println(v)
}
}
Pattern 3: Cancellation with context.Context
Need to stop workers early โ say, after a 300ms deadline โ use context.Context. Don't close the data channel from the receiver side:
package main
import (
"context"
"fmt"
"time"
)
func worker(ctx context.Context, ch chan<- int, id int) {
for {
select {
case <-ctx.Done():
fmt.Printf("worker %d: stopping\n", id)
return
case ch <- id:
time.Sleep(100 * time.Millisecond)
}
}
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
defer cancel()
ch := make(chan int, 5)
go worker(ctx, ch, 1)
go worker(ctx, ch, 2)
for {
select {
case v := <-ch:
fmt.Println("received", v)
case <-ctx.Done():
fmt.Println("main: done")
return
}
}
}
Each worker watches ctx.Done() and exits on its own. The channel stays open until all senders leave โ no panic possible.
Pattern 4: Done Channel (Stop Signal)
Not using context? A separate done channel does the same job. Close it to broadcast a stop signal to all workers at once:
package main
import (
"fmt"
"sync"
)
func worker(done <-chan struct{}, ch chan<- int, id int, wg *sync.WaitGroup) {
defer wg.Done()
for {
select {
case <-done:
return
case ch <- id:
}
}
}
func main() {
ch := make(chan int, 10)
done := make(chan struct{})
var wg sync.WaitGroup
for i := 0; i < 3; i++ {
wg.Add(1)
go worker(done, ch, i, &wg)
}
// Drain 10 values
for i := 0; i < 10; i++ {
fmt.Println(<-ch)
}
// Signal all workers, wait for them, then close data channel
close(done)
wg.Wait()
close(ch) // safe: every sender has already returned
}
Catching Panics in Tests
Write a regression test to confirm the old code panicked and your fix holds:
func TestSafeSend(t *testing.T) {
ch := make(chan int)
close(ch)
// Must NOT panic after the fix
closed := safeSend(ch, 42)
if !closed {
t.Error("expected closed=true")
}
}
Use the Race Detector Early
The -race flag won't catch panic: send on closed channel directly, but it surfaces the data races that lead to it. Run it before the panic ever shows up in production:
go run -race main.go
go test -race ./...
On a real project with 20+ goroutines, the race detector routinely catches timing bugs that manual review misses. Make it part of your CI pipeline.
Verification
After applying the fix, run through this checklist:
- Run
go test -race ./...โ zero race conditions reported. - Stress-test with
go run main.goa few times โ no panic output. - If you used
context, check that workers log "stopping" on cancellation rather than crashing. - Run
go vet ./...to catch static channel misuse before runtime.
Summary
- Sender closes, never the receiver โ this single rule prevents most channel panics.
- Multiple senders? Use a coordinator goroutine + sync.WaitGroup to close after the last send.
- Need early shutdown? Reach for context.Context or a done channel โ not a close on the data channel.
- Always run tests with -race. Timing bugs that look fine locally will appear under load.

