The panic that stops everything
You have a worker goroutine processing a batch of jobs. It finishes a task, packages the result, and tries to send it back to the main goroutine via a channel. The main goroutine already received enough data, closed the channel to signal "I'm done," and moved on to cleanup. The worker doesn't know. It executes the send. The program crashes. Not a graceful error return. A hard panic. The runtime terminates the goroutine and, if unhandled, the entire process.
Sending to a closed channel is a runtime violation in Go. The language treats this as a logic error that cannot be recovered from by normal control flow. The panic message is panic: send on closed channel. This happens because channels enforce a strict lifecycle contract. Closing a channel is a broadcast to all readers that no more data is coming. If a sender could write to a closed channel, that broadcast would be ambiguous. The runtime prevents the ambiguity by panicking immediately.
Channels are directional contracts
A channel is a typed conduit between goroutines. You can send values, receive values, and close the channel. Closing is a one-way signal. It marks the channel as finished. The convention is absolute: the sender closes the channel. The receiver never closes it. If the receiver closes it, the sender has no mechanism to detect the closure until it attempts a send, which triggers the panic.
Receiving from a closed channel behaves differently. It is safe. The runtime returns the zero value of the element type and a boolean false. This asymmetry is intentional. It allows receivers to detect shutdown cleanly. A range loop over a channel exits automatically when the channel closes. A select statement with a receive case on a closed channel selects that case immediately. The runtime distinguishes between reading and writing. Writing to a closed channel is forbidden. Reading is allowed.
The internal state of a channel tracks whether it is closed. When you call close(ch), the runtime sets a flag. Any subsequent send operation checks this flag first. If the flag is set, the runtime calls panic. There is no buffering or retry logic. The check happens before any data movement. This ensures the panic is deterministic. You never get a partial write or a silent drop.
Minimal example
Here's the simplest way to trigger the panic. Create a channel, close it, and send.
package main
func main() {
// Create an unbuffered channel for integers
ch := make(chan int)
// Close the channel immediately
close(ch)
// Attempting to send triggers a runtime panic
ch <- 42
}
The compiler accepts this code. It cannot know the channel will be closed at runtime. The error appears when you run the program. The output includes panic: send on closed channel followed by a stack trace pointing to the send line. The program exits with a non-zero status.
What the runtime does
When the goroutine executes ch <- 42, the runtime performs a sequence of checks. First, it verifies the channel is not nil. A send on a nil channel blocks forever. Next, it checks the closed flag. If the flag is true, the runtime invokes the panic mechanism. The panic unwinds the stack of the current goroutine. If you have a defer with recover, you can catch it. Otherwise, the panic propagates and crashes the program.
The panic is synchronous. It happens in the goroutine performing the send. Other goroutines continue running until they are affected by the crash or exit on their own. This is why channel panics can be subtle in large programs. One goroutine dies, but the rest might keep spinning, leaking resources, or waiting on other channels. The crash might not surface until the main goroutine exits or the process is killed.
Channels cannot be reopened. Once closed, the channel remains closed for the lifetime of the program. You cannot reset the state. If you need a fresh communication path, create a new channel. This immutability simplifies reasoning about channel lifecycle. You don't have to worry about a channel being reopened after a receiver has stopped listening.
Coordinating shutdown in real code
Real applications rarely close a channel and then blindly send. The crash occurs when coordination fails. A common pattern is to use a context.Context to signal cancellation. The context propagates the shutdown signal to all goroutines. Senders check the context before sending. If the context is cancelled, the sender stops work and returns. The channel is closed only after all senders have finished.
Here's a worker that respects cancellation. It uses a select statement to race the send against the context. If the context is cancelled, the worker exits without sending. This avoids the panic even if the receiver has already closed the channel.
package main
import (
"context"
"fmt"
)
// Worker processes items and sends results back.
// It checks context before sending to avoid panics.
func worker(ctx context.Context, results chan<- int) {
for i := 0; i < 10; i++ {
// Select races the send against cancellation
select {
case results <- i:
// Send succeeded, continue loop
case <-ctx.Done():
// Context cancelled, stop immediately
fmt.Println("Worker stopping: context cancelled")
return
}
}
}
func main() {
// Create context with cancellation
ctx, cancel := context.WithCancel(context.Background())
// Buffered channel allows one send without blocking
results := make(chan int, 1)
go worker(ctx, results)
// Receive one value, then cancel
fmt.Println(<-results)
cancel()
// Close channel after cancellation to signal readers
close(results)
}
The select statement provides a safe way to attempt a send. If the channel is closed, the send case is not ready. The runtime evaluates all cases. If the context is cancelled, the <-ctx.Done() case is ready. The select chooses that case. The worker returns. No panic occurs. The key is that the sender must check for shutdown before or during the send operation.
The convention for context is strict. Pass context.Context as the first parameter. Name it ctx. Functions should check ctx.Err() or select on ctx.Done() at appropriate points. This pattern is standard across the Go ecosystem. Libraries and frameworks expect it. Following the convention makes your code interoperable and predictable.
Pitfalls and edge cases
The most common cause of this panic is a race between closing and sending. One goroutine closes the channel. Another goroutine holds a reference and tries to send. The timing determines whether the send happens before or after the close. If after, you get the panic. This race is hard to reproduce in tests. It often appears under load or in production.
Another panic is panic: close of closed channel. This happens when two goroutines race to close the same channel. The first one succeeds. The second one panics. Use sync.Once to ensure the channel is closed exactly once. Wrap the close call in a Once value. This is a standard pattern for shared resources.
var once sync.Once
once.Do(func() { close(ch) })
Receivers should never close a channel unless they are implementing a specific protocol where the receiver signals the sender to stop. Even then, the sender must handle the signal before sending. If the receiver closes the channel, the sender must detect the closure via a separate signal, like a context or a done channel. Blindly sending after a receiver closes is a design flaw.
A range loop over a channel is safe. It exits when the channel closes. You don't need to check for closure manually. The loop handles it. This is one of the few places where the runtime abstracts away the closed state for you. Use range whenever you want to consume all values until the channel closes.
The underscore _ discards values intentionally. If you receive from a channel and don't need the value, use _. This tells the compiler you are ignoring the result. It also helps with linters. Don't use _ for errors unless you have a specific reason. Errors should be handled or returned. Dropping errors silently is a common source of bugs.
Decision matrix
Use a single-goroutine closer when one component owns the channel lifecycle and signals completion by closing.
Use a context.Context for cancellation when you need to propagate a stop signal across multiple goroutines and channel operations.
Use a buffered channel when the sender must return immediately and you can tolerate dropping data if the receiver is too slow.
Use a select statement with a default case when you need a non-blocking send that fails gracefully instead of panicking.
Use sync.Once when multiple goroutines share the responsibility of closing a channel to prevent double-close panics.
Use a separate done channel when you want to decouple the shutdown signal from the data channel entirely.