Fix

"send on closed channel" Panic in Go

Fix 'send on closed channel' panic by ensuring channels are closed only once and checking state before sending.

The panic that breaks pipelines

You are building a data pipeline. A producer goroutine generates work, a pool of workers processes it, and a main goroutine collects results. Everything runs smoothly for a few seconds, then the program crashes with panic: send on closed channel. The stack trace points to a send operation inside a worker. You check the code and see the channel is closed in the main goroutine after the workers finish. The logic looks correct, but the crash persists. The issue is not the close itself. The issue is that the close happened while a worker was still trying to send, or the close logic raced with the send logic. Go panics because sending on a closed channel is a programming error that indicates broken coordination.

Channels have a one-way lifecycle

A channel is a communication pipe between goroutines. It has two states: open and closed. Sending works only when the channel is open. Closing a channel marks it as finished. Once closed, no more values can be sent. If a goroutine attempts to send after the channel closes, the runtime triggers a panic.

The asymmetry is deliberate. Senders write data. Receivers read data. Only the sender should close the channel. Closing signals to receivers that no more data is coming. If a receiver closes the channel, it violates the contract. Receivers might still be reading, or other senders might be writing. The convention is strict: the goroutine responsible for producing all data closes the channel when production ends.

When multiple goroutines send to the same channel, you have a coordination problem. You cannot close the channel from every sender, because closing it twice panics with panic: close of closed channel. You also cannot close it arbitrarily, because other senders might still be active. You need a mechanism to ensure the channel closes exactly once, and only after all senders have finished.

Minimal reproduction

The following code demonstrates the race condition. A goroutine sends a value, while the main goroutine closes the channel immediately. The scheduler decides which operation happens first. If the close happens before the send completes, the program panics.

package main

import "fmt"

// main shows a race between sending and closing.
// This code is unsafe and will panic intermittently.
func main() {
    ch := make(chan int)

    // Start a goroutine that attempts to send.
    // The send blocks until a receiver is ready.
    go func() {
        ch <- 42
    }()

    // Closing here races with the send.
    // If the goroutine hasn't sent yet, this causes a panic.
    close(ch)

    // Drain the channel to keep main alive.
    // This receives the value if the send succeeded.
    <-ch
}

The compiler does not catch this error. The code compiles successfully because the types match and the syntax is valid. The panic occurs at runtime. The error message is panic: send on closed channel. This tells you exactly what happened: a send operation encountered a channel that was already closed. The fix requires changing the logic so the close only happens after the send is guaranteed to be done.

What the runtime does

When you call close(ch), the runtime sets a flag on the channel structure. It also wakes up any goroutines blocked on receiving, so they can see the channel is closed. The runtime does not block senders. It does not queue the close operation. The close is immediate.

When a goroutine executes ch <- val, the runtime checks the channel state. If the channel is open, it proceeds with the send. If the channel is closed, the runtime triggers a panic. The panic stops the goroutine and prints a stack trace. If you do not recover from the panic, the entire program terminates.

Reading from a closed channel behaves differently. It does not panic. It returns the zero value of the element type and a boolean false to indicate the channel is closed. This allows receivers to detect completion gracefully. The idiomatic way to read until close is using a range loop. The loop automatically checks the boolean and stops when the channel closes.

// consume reads from a channel until it closes.
// The range loop handles the closed state automatically.
func consume(ch <-chan int) {
    for val := range ch {
        fmt.Println(val)
    }
    // Loop exits here when ch is closed.
}

Convention aside: receiver names in methods should be short, usually one or two letters matching the type. (c *Channel) Close() is correct. (this *Channel) or (self *Channel) is not Go style. The community expects concise receiver names.

Real-world pattern: closing after workers

The most common scenario involves a worker pool. Multiple goroutines process items and send results to a shared channel. The main goroutine needs to know when all workers are done so it can close the channel and stop reading. The standard solution uses a sync.WaitGroup. The WaitGroup tracks the number of active workers. When the count reaches zero, a separate goroutine closes the channel.

package main

import (
    "fmt"
    "sync"
)

// runWorkers launches a pool of goroutines and closes the result channel when done.
// It uses a WaitGroup to coordinate the close.
func runWorkers() {
    results := make(chan string)
    var wg sync.WaitGroup

    // Launch three workers.
    // Each worker increments the WaitGroup before starting.
    for i := 0; i < 3; i++ {
        wg.Add(1)
        go func(id int) {
            defer wg.Done()
            // Simulate work.
            results <- fmt.Sprintf("result-%d", id)
        }(i)
    }

    // Start a goroutine to close the channel when all workers finish.
    // This goroutine blocks on wg.Wait() until the count is zero.
    go func() {
        wg.Wait()
        close(results)
    }()

    // Consume results until the channel closes.
    for r := range results {
        fmt.Println(r)
    }
}

This pattern is safe. The channel closes only after all workers call wg.Done(). Since wg.Done() happens after the send completes, the close never races with an active send. The range loop in the consumer stops cleanly when the channel closes.

Convention aside: defer wg.Done() is the standard way to ensure the counter decrements even if the goroutine panics. Place the defer immediately after wg.Add(). This prevents goroutine leaks where the WaitGroup never reaches zero.

Pitfalls and edge cases

Several mistakes lead to channel panics. Understanding them helps you write robust code.

Double close. Calling close twice on the same channel panics with panic: close of closed channel. This happens when multiple goroutines try to close the channel without coordination. Use sync.Once to guarantee idempotency if you cannot use a WaitGroup.

package main

import (
    "fmt"
    "sync"
)

// safeCloser wraps a channel with a Once to prevent double closes.
// It is useful when multiple error paths might attempt to close.
type safeCloser struct {
    ch   chan int
    once sync.Once
}

// Close attempts to close the channel exactly once.
// Subsequent calls are no-ops.
func (s *safeCloser) Close() {
    s.once.Do(func() {
        close(s.ch)
    })
}

// main demonstrates safe closing.
func main() {
    sc := &safeCloser{ch: make(chan int)}

    // First close succeeds.
    sc.Close()
    fmt.Println("Closed once")

    // Second close is ignored.
    sc.Close()
    fmt.Println("Attempted close again")
}

Closing from the receiver. Receivers should never close a channel. If a receiver closes the channel, it might close it while senders are still active. This causes panic: send on closed channel. The sender owns the lifecycle. If you need to signal the sender to stop, use a separate done channel or context.Context.

Closing a nil channel. Calling close(nil) panics with panic: close of nil channel. Always check for nil before closing, or ensure the channel is initialized. This is rare in well-structured code but can happen with optional channels.

Buffered channels do not prevent panics. A buffered channel allows sends to proceed without a receiver until the buffer is full. Closing a buffered channel does not block senders. If a sender is blocked on a full buffer and the channel closes, the send still panics. Buffering delays the panic but does not eliminate the race.

Convention aside: context.Context is plumbing. Run it through every long-lived call site. If you need to cancel work, pass a context as the first parameter, conventionally named ctx. Functions that take a context should respect cancellation and deadlines. Do not use a channel for cancellation unless you have a specific reason. Context is the standard way to propagate shutdown signals.

Decision matrix

Choose the coordination strategy that matches your data flow.

Use a sync.WaitGroup when you have a known set of goroutines that must finish before the channel closes.

Use sync.Once when multiple goroutines might attempt to close the channel, and you need to guarantee idempotency without tracking counts.

Use a done channel or context.Context when you need to signal cancellation rather than completion, allowing senders to stop gracefully.

Use a single sender pattern when possible, as it eliminates the coordination problem entirely by having one goroutine manage all sends and the close.

Use a buffered channel when you want to decouple the sender from the receiver and reduce blocking, but still coordinate the close carefully.

Where to go next