Introduction
Go send on closed channel panic occurs when code attempts to send a value to a channel that has already been closed, causing immediate application crash with panic: send on closed channel. This is one of the most common concurrency errors in Go applications. Channels in Go have specific close semantics: only the sender should close a channel (never the receiver), sending to a closed channel panics, receiving from a closed channel returns zero value immediately without blocking, and multiple closes on the same channel also panic. Common causes include closing channel in wrong goroutine, race condition where channel closed before send completes, worker pool shutdown sending to closed result channel, context cancellation not checked before channel send, deferred close executing after send attempted, channel used after function returns and closes it, and improper shutdown sequence in producer-consumer patterns. The fix requires understanding channel ownership patterns, using select with context for safe sends, implementing proper shutdown sequences, and following the principle that only the goroutine sending on a channel should close it. This guide provides production-proven patterns for safe channel communication across worker pools, event streams, pipelines, and graceful shutdown scenarios.
Symptoms
panic: send on closed channel- Application crashes during shutdown or cleanup
- Panic in worker goroutine while sending results
- Intermittent panic based on timing/race conditions
panic: close of closed channel(secondary error)- Goroutine leaks after channel-related panic
- Pipeline stages deadlock on channel operations
- Select statement sends to closed channel in default case
- Panic in deferred function closing channel
- Worker pool crashes during scale-down
Common Causes
- Receiver closes channel instead of sender
- Multiple goroutines closing same channel
- Channel closed while other goroutines still sending
- Deferred close runs after send attempted in different goroutine
- Context cancelled but send attempted anyway
- Worker pool shutdown sequence incorrect
- Range loop over channel continues after close
- Channel used after function scope ends
- Race condition between close and send operations
- Cleanup code closes channel prematurely
Step-by-Step Fix
### 1. Understand channel close semantics
Channel close behavior:
```go // Create channel ch := make(chan int, 2)
// Send values ch <- 1 ch <- 2
// Close channel (only sender should do this) close(ch)
// After close: // - Sending PANICS: ch <- 3 // panic: send on closed channel // - Receiving returns existing values: <-ch returns 1, then 2 // - After buffer empty: <-ch returns zero value (0 for int) // - ok flag indicates if value was sent before close: val, ok := <-ch
// Close twice also PANICS close(ch) // panic: close of closed channel ```
Who should close channels:
```go // RULE: Only the SENDER closes the channel // Receiver should NEVER close a channel it receives on
// CORRECT pattern: Sender closes func producer(ch chan<- int) { defer close(ch) // Sender closes when done ch <- 1 ch <- 2 }
func consumer(ch <-chan int) { for v := range ch { // Receiver just reads fmt.Println(v) } // Receiver does NOT close }
// INCORRECT: Receiver closing func wrongConsumer(ch <-chan int) { for v := range ch { fmt.Println(v) } close(ch) // WRONG! This goroutine didn't send } ```
Directional channel parameters:
```go // Channel direction in function signatures func producer(ch chan<- int) { // Send-only ch <- 42 close(ch) // Can close send-only channel }
func consumer(ch <-chan int) { // Receive-only val := <-ch // Can receive from receive-only channel // ch <- 1 // Compile error: can't send on receive-only // close(ch) // Compile error: can't close receive-only }
// Bidirectional (default) func both(ch chan int) { ch <- 1 val := <-ch close(ch) } ```
### 2. Fix basic send on closed channel
Use sender-owned close pattern:
```go // WRONG: Receiver closes channel func wrong() { ch := make(chan int)
go func() { ch <- 42 // Sender doesn't close }()
val := <-ch close(ch) // WRONG! Receiver closing }
// CORRECT: Sender closes func correct() { ch := make(chan int)
go func() { defer close(ch) // Sender closes when done ch <- 42 }()
val := <-ch // Receiver doesn't close } ```
Handle multiple senders safely:
```go // PROBLEM: Multiple senders, who closes? // WRONG: Multiple goroutines might all try to close func wrongMultiple() { ch := make(chan int)
for i := 0; i < 3; i++ { go func(id int) { ch <- id close(ch) // ALL three will try to close! }(i) } }
// CORRECT 1: Use WaitGroup, one goroutine closes func correctMultiple() { ch := make(chan int) var wg sync.WaitGroup
for i := 0; i < 3; i++ { wg.Add(1) go func(id int) { defer wg.Done() ch <- id }(i) }
// Separate goroutine waits for all senders, then closes go func() { wg.Wait() close(ch) // Only close after all senders done }()
// Consume for val := range ch { fmt.Println(val) } }
// CORRECT 2: Use context for cancellation func withContext(ctx context.Context) { ch := make(chan int)
go func() { defer close(ch) select { case ch <- 42: case <-ctx.Done(): // Exit if context cancelled return } }() } ```
### 3. Use select for safe channel operations
Select with context cancellation:
```go // Safe send with timeout func safeSend(ch chan<- int, val int, timeout time.Duration) error { select { case ch <- val: return nil // Send succeeded case <-time.After(timeout): return fmt.Errorf("send timeout") // Timeout } }
// Safe send with context func safeSendCtx(ctx context.Context, ch chan<- int, val int) error { select { case ch <- val: return nil case <-ctx.Done(): return ctx.Err() // Context cancelled or deadline exceeded } }
// Usage ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel()
err := safeSendCtx(ctx, ch, 42) if err != nil { log.Printf("Send failed: %v", err) } ```
Detect closed channel in select:
```go // Receive with closed channel detection func receiveWithDetect(ch <-chan int) (val int, ok bool) { select { case val, ok = <-ch: return val, ok // ok=false means channel closed default: return 0, false // Non-blocking: no value available } }
// Handle multiple channels with close detection func handleMultiple(ch1, ch2 <-chan int, done <-chan struct{}) { for { select { case val, ok := <-ch1: if !ok { log.Println("ch1 closed") ch1 = nil // Ignore this channel now } else { process(val) } case val, ok := <-ch2: if !ok { log.Println("ch2 closed") ch2 = nil } else { process(val) } case <-done: return // Exit requested }
// Exit if all channels closed if ch1 == nil && ch2 == nil { return } } } ```
### 4. Fix worker pool shutdown patterns
Graceful worker pool shutdown:
```go type WorkerPool struct { jobs chan Job results chan Result workers int wg sync.WaitGroup }
func NewWorkerPool(workers int) *WorkerPool { return &WorkerPool{ jobs: make(chan Job, workers), results: make(chan Result, workers), workers: workers, } }
func (wp *WorkerPool) Start(ctx context.Context) { // Start workers for i := 0; i < wp.workers; i++ { wp.wg.Add(1) go func() { defer wp.wg.Done() for job := range wp.jobs { // Check context before processing select { case <-ctx.Done(): return default: result := processJob(job)
// Safe send with context check select { case wp.results <- result: case <-ctx.Done(): return } } } }() }
// Start result collector (closes results channel) go func() { wp.wg.Wait() // Wait for all workers close(wp.results) // Only close after all workers done }() }
func (wp *WorkerPool) Submit(job Job) error { select { case wp.jobs <- job: return nil case <-ctx.Done(): return ctx.Err() } }
func (wp *WorkerPool) Shutdown() { close(wp.jobs) // Close jobs channel (sender closing) wp.wg.Wait() // Wait for workers to finish // results channel closed by collector goroutine } ```
Pipeline with graceful shutdown:
```go func pipeline(input <-chan int, done <-chan struct{}) <-chan int { out := make(chan int)
go func() { defer close(out) // This goroutine sends, so it closes
for { select { case val, ok := <-input: if !ok { return // Input closed, exit }
// Process and send result := val * 2
select { case out <- result: case <-done: // Check for shutdown signal return }
case <-done: return // Shutdown requested } } }()
return out }
// Usage func main() { done := make(chan struct{}) input := make(chan int)
// Start pipeline output := pipeline(input, done)
// Send input go func() { defer close(input) for i := 0; i < 10; i++ { input <- i } }()
// Collect output go func() { for result := range output { fmt.Println(result) } }()
// Shutdown if needed // close(done) // Signal all stages to stop } ```
### 5. Fix deferred close patterns
Correct defer placement:
```go // WRONG: Defer in different goroutine than send func wrong() { ch := make(chan int)
go func() { ch <- 42 // Send happens here }()
defer close(ch) // Defer closes immediately after function returns // But send might happen after function returns! }
// CORRECT: Defer in same goroutine as sends func correct() { ch := make(chan int)
go func() { defer close(ch) // Close after all sends complete ch <- 42 ch <- 43 }()
// Consume for val := range ch { fmt.Println(val) } } ```
Handle early return with defer:
```go func processWithEarlyReturn(ch chan<- int, shouldStop bool) { // Defer will run even with early return // But only if defer is in SAME function as sends
if shouldStop { return // Defer still runs }
ch <- 42 }
// For complex cleanup, use explicit close func processExplicit(ch chan<- int, shouldStop bool) { if shouldStop { close(ch) // Explicit close return }
ch <- 42 close(ch) // Normal close } ```
Prevent double close:
```go // Use sync.Once for safe close type SafeChannel struct { ch chan int once sync.Once }
func (sc *SafeChannel) Close() { sc.once.Do(func() { close(sc.ch) // Only closes once, even if called multiple times }) }
func (sc *SafeChannel) Send(val int) error { select { case sc.ch <- val: return nil case <-sc.closed(): return fmt.Errorf("channel closed") } }
func (sc *SafeChannel) closed() <-chan struct{} { // Return a channel that's closed when sc.ch is closed // This is a simplified version return nil }
// Alternative: Use atomic flag type SafeChannel2 struct { ch chan int closed atomic.Bool }
func (sc *SafeChannel2) Close() { if sc.closed.CompareAndSwap(false, true) { close(sc.ch) } }
func (sc *SafeChannel2) Send(val int) error { if sc.closed.Load() { return fmt.Errorf("channel closed") } sc.ch <- val return nil } ```
### 6. Fix range loop issues
Handle range after channel close:
```go // Range automatically stops when channel closes func rangeExample(ch <-chan int) { for val := range ch { process(val) } // Loop exits cleanly when channel closes // No panic here }
// PROBLEM: Sending after range starts but before close func rangeProblem() { ch := make(chan int)
go func() { for val := range ch { fmt.Println(val) // If this takes long, sender might be stuck } }()
ch <- 1 // What if we need to send more but receiver exited? // Solution: Use context for coordination } ```
Range with multiple channels:
go
// Can't range over multiple channels directly
// Use select instead
func multiRange(ch1, ch2 <-chan int) {
for ch1 != nil || ch2 != nil {
select {
case val, ok := <-ch1:
if !ok {
ch1 = nil // Mark as closed
continue
}
process(val)
case val, ok := <-ch2:
if !ok {
ch2 = nil
continue
}
process(val)
}
}
}
### 7. Debug channel-related panics
Add panic recovery:
```go func safeGoroutine() { defer func() { if r := recover(); r != nil { if err, ok := r.(error); ok { if strings.Contains(err.Error(), "send on closed channel") { log.Printf("Channel send panic: %v", err) // Log stack trace for debugging buf := make([]byte, 4096) n := runtime.Stack(buf, false) log.Printf("Stack: %s", buf[:n]) } } } }()
// Code that might panic go func() { ch <- 42 // Might panic if ch closed }() } ```
Add channel operation logging:
```go type TracedChannel struct { ch chan int name string closed atomic.Bool }
func NewTracedChannel(name string) *TracedChannel { return &TracedChannel{ ch: make(chan int), name: name, } }
func (tc *TracedChannel) Send(val int) error { if tc.closed.Load() { log.Printf("[%s] Attempted send on closed channel", tc.name) return fmt.Errorf("send on closed channel") }
log.Printf("[%s] Sending: %d", tc.name, val) tc.ch <- val return nil }
func (tc *TracedChannel) Close() { log.Printf("[%s] Closing channel", tc.name) tc.closed.Store(true) close(tc.ch) }
func (tc *TracedChannel) Recv() (int, bool) { val, ok := <-tc.ch if ok { log.Printf("[%s] Received: %d", tc.name, val) } else { log.Printf("[%s] Channel closed", tc.name) } return val, ok } ```
Prevention
- Only the sender goroutine should close a channel
- Use
defer close(ch)immediately after creating channel in sender goroutine - Use select with context.Done() for all sends in long-running goroutines
- Implement proper shutdown sequences with WaitGroup
- Use sync.Once or atomic flags for close-once semantics
- Document channel ownership in function comments
- Use directional channel parameters (<-chan, chan<-) in signatures
- Add panic recovery around critical goroutines during debugging
- Use race detector:
go test -raceto find channel races - Consider using higher-level abstractions (errgroup, pipeline patterns)
Related Errors
- **Go goroutine leak**: Goroutines not exiting after channel operations
- **Go channel deadlock**: All goroutines waiting on channel operations
- **Go context deadline exceeded**: Timeout in context operations
- **Go nil pointer dereference**: Dereferencing nil pointer
- **Go interface conversion panic**: Type assertion on interface{} fails