Fix: "send on closed channel" in Go
You're running a concurrent data pipeline. A producer goroutine fetches records and pushes them into a channel. A consumer reads the records and writes them to storage. You run the program, and it crashes. The stack trace points to a send operation, and the runtime halts execution with a panic. The message is explicit: send on closed channel. This panic occurs when your code attempts to write a value into a channel that has already been marked as finished. The fix requires identifying which goroutine closes the channel and ensuring that closure happens only after every sender has completed its work.
The channel contract
A channel in Go is a communication bridge between goroutines. You send values into one end and receive them from the other. Closing a channel signals that no more values will be sent. It is a broadcast message to all receivers: "I am done. Drain what remains in the buffer, then stop." The panic happens when you ignore that signal and try to force a value through a channel that has already been declared finished. The runtime protects you from this because a closed channel is a one-way street for receivers only. Senders are forbidden.
Think of a channel like a mailbox with a lock. While the lock is open, anyone can drop a letter inside. When you lock the mailbox, you are signaling that no more letters will be accepted. If someone tries to stuff a letter into a locked mailbox, the system stops them immediately. In Go, the runtime is the guard. It panics to prevent silent data corruption. If you could send on a closed channel, receivers might miss the completion signal or process data out of order. The panic forces you to fix the logic.
Closing a channel is a signal. Only the sender sends the signal.
Minimal example: single sender
The safest pattern is a single goroutine responsible for sending values and closing the channel. This goroutine owns the lifecycle of the channel. It sends all values, then closes the channel. Receivers read until the channel closes.
package main
import "fmt"
// SafeWorker sends integers and closes the channel upon completion.
// Only the sender should close the channel.
func SafeWorker(ch chan<- int) {
// Defer close ensures the channel closes even if the function returns early.
defer close(ch)
for i := 0; i < 5; i++ {
ch <- i
}
}
func main() {
ch := make(chan int)
// Launch the worker in a separate goroutine.
go SafeWorker(ch)
// Range blocks until the channel is closed and drained.
for val := range ch {
fmt.Println(val)
}
}
The function signature uses chan<- int. This direction restriction tells the compiler that SafeWorker can only send to the channel. It cannot receive, and it cannot close the channel from the outside. The compiler enforces this direction, which prevents accidental misuse. The defer close(ch) statement guarantees that the channel closes when the function returns, whether the loop finishes normally or an error causes an early return. This prevents goroutine leaks where receivers block forever waiting for a close signal.
Defer the close. Keep the sender responsible.
Runtime behavior: what happens when you close
When you call close(ch), the runtime marks the channel as closed. The internal state of the channel flips a flag. Any subsequent receive operation checks this flag. If the channel has buffered values, those values are returned to the receiver. Once the buffer is empty, receives return the zero value for the channel type and a boolean false. This allows receivers to detect completion using the value, ok := <-ch idiom.
If a sender tries to write to a closed channel, the runtime checks the flag, sees it is closed, and triggers a panic immediately. There is no recovery. The program halts. This design is intentional. It makes concurrency bugs visible. A silent failure would be far worse than a panic. The panic points directly to the line causing the issue, making debugging straightforward.
The runtime enforces the contract. Trust the panic.
Realistic example: multiple senders
The panic often appears when multiple goroutines share a channel. If each goroutine tries to close the channel when it finishes, the first one to finish closes the channel, and the others panic when they try to send. Or, if two goroutines race to close, one might close while the other is sending. The solution is to coordinate the senders and have a single goroutine close the channel after all senders are done.
A sync.WaitGroup tracks the active senders. Each sender calls Done when it finishes. A dedicated closer goroutine waits for the count to reach zero, then closes the channel.
package main
import (
"fmt"
"sync"
)
// MultiSender sends values and signals completion via the WaitGroup.
// It never closes the channel.
func MultiSender(ch chan<- int, wg *sync.WaitGroup) {
defer wg.Done()
for i := 0; i < 5; i++ {
ch <- i
}
}
func main() {
ch := make(chan int)
var wg sync.WaitGroup
// Start three senders.
for i := 0; i < 3; i++ {
wg.Add(1)
go MultiSender(ch, &wg)
}
// Close the channel in a separate goroutine after all senders complete.
go func() {
wg.Wait()
close(ch)
}()
// Range blocks until the channel closes and the buffer drains.
for val := range ch {
fmt.Println(val)
}
}
The MultiSender function takes a chan<- int and a pointer to the WaitGroup. It sends values and calls wg.Done() when finished. It does not close the channel. The main function starts three senders and increments the WaitGroup counter for each. A separate goroutine calls wg.Wait(), which blocks until all senders have called Done. Once the counter reaches zero, the closer goroutine closes the channel. This ensures the channel closes exactly once, after all sends are complete.
Channel direction is a convention that pays off. Use chan<- for send-only and <-chan for receive-only in function signatures. This restricts the API and prevents accidental closes or sends inside the function body. The compiler enforces these directions, catching errors at compile time rather than runtime.
Coordinate senders. Close once.
Pitfalls and compiler errors
The most common cause of this panic is multiple goroutines closing the same channel. If two goroutines race to close, one might close while the other is sending, or both might close. The runtime panics with close of closed channel if you attempt to close a channel that is already closed. This error is distinct from the send panic but stems from the same root cause: violating the single-closer rule.
Another pitfall is closing the channel from the receiver side. Receivers should never close a channel. Closing from the receiver side breaks the contract and risks a send panic if the sender hasn't finished. The receiver's job is to read values and react to the close signal. If the receiver closes the channel, it masks the sender's completion signal and can cause the sender to panic.
A third issue is closing a channel while a send is in progress. If a sender is blocked waiting for a receiver, and another goroutine closes the channel, the blocked sender will panic when it wakes up. The runtime does not distinguish between a send that started before the close and one that started after. Any send on a closed channel panics.
The runtime panics with send on closed channel when a send operation targets a closed channel. The panic message includes the stack trace, which points to the line performing the send. Use the stack trace to find the send site, then trace back to find the code that closed the channel. Check if multiple goroutines have access to the channel and if any of them call close. Check if the receiver closes the channel. Check if a defer close runs too early.
One closer per channel. Multiple closers break the contract.
Decision matrix
Use a single sender goroutine with defer close(ch) when one producer feeds one or more consumers.
Use a sync.WaitGroup with a dedicated closer goroutine when multiple goroutines send to the same channel.
Use a buffered channel when the sender and receiver operate at different speeds and you want to decouple them slightly.
Use context.Context to cancel long-running senders when the receiver stops early and you need to clean up resources.
Use a select statement with a done channel when you need to interrupt a send operation without blocking forever.
Match the pattern to the concurrency shape.