send on closed channel
Go Programming Language
Severity: ModerateWhat Does This Error Mean?
The 'send on closed channel' panic in Go occurs when you try to send a value to a channel that has already been closed with close(). Go panics immediately when this happens because sending to a closed channel is a programming error — once closed, a channel is done. The fix is to ensure you never send to a channel after closing it, typically by coordinating which goroutine owns the right to close the channel. Only the sender should close a channel, and only once.
Affected Models
- Go 1.20+
- Go 1.21+
- Go 1.22+
- go build
- Any Go project
Common Causes
- A goroutine sends to a channel after another goroutine has already called close() on it
- A channel is closed in one goroutine and a race condition allows sends to arrive after the close
- close() is called on the channel too early — before all senders have finished sending
- Multiple goroutines all attempt to close the same channel, and one closes it while others are still sending
- Error handling code closes a channel in a cleanup path while the main path is still sending
How to Fix It
-
Follow the Go convention: only the sender goroutine (or the coordinator) should close a channel. Receivers should never close a channel. This prevents 'send on closed channel' by ensuring the entity closing the channel knows when all sends are done.
Think of it like this: the person filling a box closes it when they are done — the person receiving it does not reach in and close it while the sender is still filling it.
-
Use sync.WaitGroup to coordinate multiple senders. Start a goroutine that waits for all senders to finish using wg.Wait(), then closes the channel: go func() { wg.Wait(); close(ch) }()
This pattern ensures the channel is closed exactly once, only after all senders have confirmed they are finished.
-
Use a sync.Once to ensure close() is called at most once. If multiple goroutines might close the channel, wrap close() in a sync.Once: var once sync.Once; once.Do(func() { close(ch) })
sync.Once is the safest way to close a channel when you cannot guarantee exactly one caller — it makes the close operation idempotent.
-
Run your tests with the race detector enabled: go test -race or go run -race. The race detector identifies data races and concurrency issues that lead to 'send on closed channel' panics.
The race detector has some runtime overhead but is invaluable for finding concurrency bugs. Always use it during development and testing.
-
Use a context.Context with cancellation instead of closing a channel for shutdown signaling. When you want goroutines to stop, cancel the context — goroutines check ctx.Done() instead of reading from a close-signaling channel.
Context cancellation is more idiomatic for signaling goroutines to stop work, while channels are better for passing data.
When to Call a Professional
This is a concurrency bug in Go code — no external service is needed. The fix involves redesigning how you coordinate channel ownership and closure. Race detectors and testing tools can help identify the timing of this panic.
Frequently Asked Questions
Can I check if a channel is closed before sending?
Not safely — Go has no built-in way to check if a channel is closed without receiving from it. A select statement with a default case can be used to avoid blocking, but not to check closed status before sending. The correct approach is to design your code so that the sender always knows when it is safe to send, without needing to check. This is achieved through proper goroutine coordination using WaitGroups, contexts, or ownership conventions.
What happens when you receive from a closed channel?
Receiving from a closed channel is safe and well-defined in Go. If the channel still has buffered values, they are received normally. Once the buffer is empty, receives return the zero value for the channel's element type and false for the ok value. For example: val, ok := <-ch — if ok is false, the channel is closed and drained. This is intentional — receivers use this to detect when a producer is done.
Is 'send on closed channel' catchable with recover()?
Yes — a deferred recover() will catch the panic from sending on a closed channel. However, using recover() to mask this panic is strongly discouraged. The panic indicates a programming bug (incorrect channel lifecycle management), not an expected error condition. Fix the root cause instead of recovering from it. recover() is appropriate for handling truly unexpected panics in library code, not for hiding concurrency bugs.