Fix 'panic: send on closed channel' in Go Goroutines

intermediate๐Ÿ”ท Go2026-03-21| Go 1.18+, Linux / macOS / Windows, any program using goroutines and channels

Error Message

panic: send on closed channel
#goroutine#channel#concurrency#panic

The Error

Your Go program crashes with:

goroutine 1 [running]:
main.main()
        /home/user/app/main.go:25 +0x68
goroutine 6 [running]:
main.worker()
        /home/user/app/main.go:14 +0x44
panic: send on closed channel

A goroutine tried to push a value into a channel that was already closed. Reading from a closed channel is fine โ€” Go returns the zero value and moves on. Sending is different. Any send on a closed channel panics immediately, no exceptions.

Root Cause

Four situations reliably trigger this panic:

  • One goroutine closes the channel while another is still sending to it.
  • The channel gets closed twice (that also panics, but with panic: close of closed channel).
  • A worker goroutine keeps running after the coordinator already called close(ch).
  • A timeout or cancellation tears down a pipeline while producers are mid-send.

Minimal reproduction โ€” hard to miss:

package main

func main() {
    ch := make(chan int)
    close(ch)   // channel closed here
    ch <- 1     // panic: send on closed channel
}

Real codebases hit a subtler version. The close happens before the goroutines even start:

package main

import (
    "fmt"
    "sync"
)

func main() {
    ch := make(chan int, 10)
    var wg sync.WaitGroup

    // Closed too early โ€” workers haven't launched yet
    close(ch)

    for i := 0; i < 5; i++ {
        wg.Add(1)
        go func(id int) {
            defer wg.Done()
            ch <- id // panic: send on closed channel
        }(i)
    }

    wg.Wait()
    fmt.Println("done")
}

Quick Fix: Recover from the Panic (Not for Production)

Go has no built-in isClosed() check. You can wrap the send in a defer recover() to absorb the panic:

func safeSend(ch chan int, val int) (closed bool) {
    defer func() {
        if r := recover(); r != nil {
            closed = true
        }
    }()
    ch <- val
    return false
}

It works. It's also a band-aid over the real problem: unclear channel ownership. Fix the design instead.

Permanent Fix: The Sender Owns the Close

Go's unwritten rule: only the sender closes a channel, never the receiver. Close it after every send is done, and only once.

Pattern 1: Single Producer

One goroutine owns the channel entirely. It sends, then closes. Clean and simple:

package main

import "fmt"

func produce(ch chan<- int) {
    for i := 0; i < 5; i++ {
        ch <- i
    }
    close(ch) // safe: only this goroutine sends
}

func main() {
    ch := make(chan int)
    go produce(ch)

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

Pattern 2: Multiple Producers โ€” Coordinator + sync.WaitGroup

Five goroutines sending to one channel? None of them should close it. Hand that job to a coordinator:

package main

import (
    "fmt"
    "sync"
)

func main() {
    ch := make(chan int, 10)
    var wg sync.WaitGroup

    for i := 0; i < 5; i++ {
        wg.Add(1)
        go func(id int) {
            defer wg.Done()
            ch <- id // safe: channel is still open
        }(i)
    }

    // This goroutine waits for all 5 senders, then closes
    go func() {
        wg.Wait()
        close(ch)
    }()

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

Pattern 3: Cancellation with context.Context

Need to stop workers early โ€” say, after a 300ms deadline โ€” use context.Context. Don't close the data channel from the receiver side:

package main

import (
    "context"
    "fmt"
    "time"
)

func worker(ctx context.Context, ch chan<- int, id int) {
    for {
        select {
        case <-ctx.Done():
            fmt.Printf("worker %d: stopping\n", id)
            return
        case ch <- id:
            time.Sleep(100 * time.Millisecond)
        }
    }
}

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
    defer cancel()

    ch := make(chan int, 5)

    go worker(ctx, ch, 1)
    go worker(ctx, ch, 2)

    for {
        select {
        case v := <-ch:
            fmt.Println("received", v)
        case <-ctx.Done():
            fmt.Println("main: done")
            return
        }
    }
}

Each worker watches ctx.Done() and exits on its own. The channel stays open until all senders leave โ€” no panic possible.

Pattern 4: Done Channel (Stop Signal)

Not using context? A separate done channel does the same job. Close it to broadcast a stop signal to all workers at once:

package main

import (
    "fmt"
    "sync"
)

func worker(done <-chan struct{}, ch chan<- int, id int, wg *sync.WaitGroup) {
    defer wg.Done()
    for {
        select {
        case <-done:
            return
        case ch <- id:
        }
    }
}

func main() {
    ch := make(chan int, 10)
    done := make(chan struct{})
    var wg sync.WaitGroup

    for i := 0; i < 3; i++ {
        wg.Add(1)
        go worker(done, ch, i, &wg)
    }

    // Drain 10 values
    for i := 0; i < 10; i++ {
        fmt.Println(<-ch)
    }

    // Signal all workers, wait for them, then close data channel
    close(done)
    wg.Wait()
    close(ch) // safe: every sender has already returned
}

Catching Panics in Tests

Write a regression test to confirm the old code panicked and your fix holds:

func TestSafeSend(t *testing.T) {
    ch := make(chan int)
    close(ch)

    // Must NOT panic after the fix
    closed := safeSend(ch, 42)
    if !closed {
        t.Error("expected closed=true")
    }
}

Use the Race Detector Early

The -race flag won't catch panic: send on closed channel directly, but it surfaces the data races that lead to it. Run it before the panic ever shows up in production:

go run -race main.go
go test -race ./...

On a real project with 20+ goroutines, the race detector routinely catches timing bugs that manual review misses. Make it part of your CI pipeline.

Verification

After applying the fix, run through this checklist:

  • Run go test -race ./... โ€” zero race conditions reported.
  • Stress-test with go run main.go a few times โ€” no panic output.
  • If you used context, check that workers log "stopping" on cancellation rather than crashing.
  • Run go vet ./... to catch static channel misuse before runtime.

Summary

  • Sender closes, never the receiver โ€” this single rule prevents most channel panics.
  • Multiple senders? Use a coordinator goroutine + sync.WaitGroup to close after the last send.
  • Need early shutdown? Reach for context.Context or a done channel โ€” not a close on the data channel.
  • Always run tests with -race. Timing bugs that look fine locally will appear under load.

Related Error Notes