skip to content

A Go pipeline panics with `send on closed channel` in its decode stage — what does that tell you about a downstream stage?

level: seniorimportance: should knowfreq 42%

answer

  1. the panic lands on the sender, not the closer
  2. close succeeds; the next send does not
  3. who else can name that channel?
  4. a receiver must never close its input
  5. two closers report a different message

basics

~20 s

Something other than decode closed decode's output channel while decode was still sending, almost always the next stage closing its input. Only the sending goroutine may close a channel; returning stages as receive-only makes a downstream close a compile error.

solid answer

~50 s

The panic always fires on the sender, so the stack trace names the victim rather than the culprit. `close` succeeds for whoever calls it, and the sender's next `out <- v` on that now-closed channel panics. In a pipeline the culprit is nearly always the stage downstream: it took its input as a bidirectional `chan T` and closed it on an early return or in a `defer` labelled cleanup. Confirm it by finding every `close(` on that channel: the stage that called `make` should hold the only one. A second close reports `close of closed channel` instead, which distinguishes a double-closer from a live sender. The structural fix is the stage signature `func stage(in <-chan T) <-chan T`: close is not an operation on a receive-only channel, so the mistake stops compiling, and a stage that wants to stop early signals with a context or done channel and lets each sender close its own output.

code

go · 14 lines
go
func resize(in chan Frame) <-chan Frame { // bidirectional input: the bug
	out := make(chan Frame)
	go func() {
		defer close(out)
		for f := range in {
			if f.Corrupt {
				close(in) // decode is still sending: decode panics, not this stage
				return
			}
			out <- shrink(f)
		}
	}()
	return out
}

go deeper

for a junior

Remember the two panics close can cause: sending on a channel someone already closed, and closing a channel twice. Both mean a channel ended up with more than one owner.

for a middle

Explain that close succeeds for the caller while the panic surfaces in a different goroutine, so the trace names the sender. Then show the receive-only stage signature that makes the mistake fail to compile.

for a senior

Walk the diagnosis end to end: read the trace as the victim, find every close on that channel, and replace the reflex to clean up an input with a cancellation signal that lets each sender close its own output.

for a principal

Set the rule once for the codebase — stage boundaries are receive-only, make and close live together, stopping early is a signal rather than a close — so this class of panic is prevented by types and review instead of found in production.

## Read the panic as the victim, not the culprit `send on closed channel` is raised in the goroutine performing the send, at the moment it sends. The goroutine that called `close` is long gone from the picture: closing an open channel always succeeds, and the runtime does not check whether anyone is parked on it or about to send. So the stack trace hands you the stage that was still working, and the bug lives in whichever other goroutine holds a reference to the same channel. In a chain of decode → resize → encode, a panic inside decode's send means somebody closed **decode's output**. Decode created it and is the only stage that should ever close it, so the reference must have escaped: decode's output is resize's input, and resize was written as `func resize(in chan Frame)` — bidirectional — which is enough to make `close(in)` compile. ## Why a receiver must never close Closing is a message from the sending side: "no more values are coming." It is meaningful only from the one goroutine that knows that fact. A receiver cannot know it; another sender may be halfway through producing the next value. That is why Go gives you no way to ask whether a channel is closed before sending — there is no such check, and any check you imagine would be racy anyway. Ownership is the only workable discipline: whoever makes the channel and sends on it closes it, and nobody else names `close` for it. The instinct that produces this bug is imported from file handles and network connections, where the consumer closes what it is finished reading. A channel is not that kind of resource. It needs no release: once unreferenced it is collected, closed or not. ## Telling the two panics apart - `send on closed channel` — one closer, and a sender that was still running underneath it. Look for a receiver that closes its input, or for a second goroutine sending on a channel someone else finished with. - `close of closed channel` — two closers on the same channel. The first close succeeded; the second panics. Look for duplicated cleanup, typically a stage that closes its output both after the loop and in a `defer`, or two stages that both believe they own the channel. - `close of nil channel` — a channel variable that was never given a value by `make`; usually a struct field left at its zero value. Those three messages are a fast triage: the first is an ownership escape, the second is duplicated ownership, the third is a missing initialisation. ## The diagnosis walk 1. Read the trace: it names the sending goroutine, its stage function and the exact send line. That is your channel's identity. 2. Find every `close(` for that channel in the package. The stage that called `make` should hold the only one; anything else is the culprit. 3. If the channel is passed around as a bidirectional `chan T`, every holder is a suspect. Retype the boundary as `<-chan T` and let the compiler point at each offending line for you. 4. Replace the closing with a signal. A downstream stage that wants to stop the pipeline early cancels the shared context, or closes a separate `done chan struct{}` that nobody sends on. Closing a done channel is safe precisely because it is a broadcast to receivers with no senders at all. Each upstream stage selects on that signal at its own send and closes its own output on the way out. ## Why not just recover? Wrapping the send in a deferred `recover` inside the sending stage does stop the process from dying, and it is the wrong answer in an interview. By the time it fires, the channel that carried the pipeline's work is closed, the values in flight are lost, and the remaining stages are about to unwind in an order nobody designed. Worse, it converts a loud, reproducible crash into silent data loss. Recovery has a place at a goroutine boundary that must not take the process down with it; it is not a substitute for deciding who owns the channel. ## The rule to state Every stage: `make` its own output, send on it, `defer close` it, return it as `<-chan T`; never call `close` on anything it did not make. `make` and `close` live in the same function, and the type system enforces the rest.

  • The trace names the sending line but not the closer — how do you find the closer?
    The trace only ever shows the victim. Find every `close(` on that channel in the package: the stage that called `make` should hold the only one. If the channel is handed around as a bidirectional `chan T`, every holder is a suspect, so retype the stage boundary as `<-chan T` and let the compiler mark each offending line.
  • How is `close of closed channel` different from `send on closed channel`?
    `close of closed channel` means two goroutines both called close and the second one panicked — duplicated ownership, with no live sender involved. `send on closed channel` means a sender was still running when someone closed the channel underneath it. The first points at repeated cleanup; the second at a receiver closing its input.
  • A downstream stage wants to stop the pipeline early — what does it do instead of closing its input?
    It signals: cancel the context the whole chain shares, or close a separate `done chan struct{}` that nobody ever sends on. Closing a done channel is safe exactly because it is a broadcast to receivers with no senders. Each upstream stage selects on that signal at its send and closes its own output on the way out.

saying these in an interview costs you the question

  • Blames the sending stage named in the panic's stack trace
  • Says a stage should close its input to release the channel
  • Wraps the send in recover instead of fixing the ownership
  • Claims you can check whether a channel is closed before sending
  • Passes channels between stages as bidirectional chan T