Why Cannot Close a Go Channel Twice

Every time someone asks why closing a channel twice panics, the real question is usually about ownership.

A channel has exactly two states: open and closed. close(ch) flips a bit in the runtime and wakes up any blocked receivers. That’s it. There’s no “half-closed” state, no reference counting, no reopening.

After it’s closed:

The important part is that closing mutates shared state. It’s not a local operation. If two goroutines both think they’re responsible for closing the same channel, one of them is wrong.

Go chooses to panic instead of silently ignoring the second call. That’s intentional. If double-close were ignored, ownership bugs would quietly survive until something worse happened.


Why There’s No isClosed

You’ll sometimes see people wishing for:

if !isClosed(ch) {
    close(ch)
}

Even if such a function existed, it wouldn’t solve anything. Between the check and the close, another goroutine could close it. The race is still there.

The only real solution is serialization.


Single Producer: Easy Case

If one goroutine produces values, it owns the close. That’s the clean model:

func producer(ch chan<- int) {
    defer close(ch)
    for i := 0; i < 10; i++ {
        ch <- i
    }
}

Receivers just range:

for v := range ch {
    fmt.Println(v)
}

Nothing controversial here.


Multiple Producers: Where It Breaks

This is wrong:

go func() {
    ch <- 1
    close(ch)
}()

go func() {
    ch <- 2
    close(ch) // panic, eventually
}()

Neither goroutine has exclusive ownership of the lifecycle.

The usual pattern is to separate production from closure:

var wg sync.WaitGroup
ch := make(chan int)

producer := func(id int) {
    defer wg.Done()
    ch <- id
}

wg.Add(2)
go producer(1)
go producer(2)

go func() {
    wg.Wait()
    close(ch)
}()

The close happens exactly once, after all producers finish. Ownership is explicit.


“Just Ignore the Second Close”

You can’t.

Not safely.

If you need “at most once” semantics, you serialize the mutation:

var once sync.Once

func safeClose(ch chan struct{}) {
    once.Do(func() {
        close(ch)
    })
}

Now the close has a clear synchronization boundary.


Data Completion vs Cancellation

Closing a data channel signals “no more values.” Cancellation is different.

For cancellation, use context:

select {
case ch <- value:
case <-ctx.Done():
    return
}

ctx.Done() is a channel that the runtime guarantees will be closed once. It avoids the ownership ambiguity entirely.


The panic on double close isn’t about being strict. It’s about forcing you to define who owns the channel’s lifecycle.

If more than one goroutine might close it, the design isn’t finished.