In a Go pipeline, why does each stage return a receive-only `<-chan Out` and close only that channel?
answer
- one maker, one closer
- the goroutine that sends is the one that closes
- a send on a closed channel panics
- defer close(out) at the top of the goroutine
- the return type can forbid a close
basics
~20 sA stage owns the one channel it creates and sends on, so only it may close that channel, in a defer when its input runs dry. Returning that channel receive-only makes any downstream close or send a compile error.
solid answer
~40 sA pipeline stage takes `<-chan In`, makes its own `out` channel, starts one goroutine that ranges over the input and sends results, and returns `out` immediately. The rule is sender-closes: a send on a closed channel panics, so the only goroutine that can safely close a channel is the one sending on it. Each stage therefore does `defer close(out)` inside its goroutine and never touches its input's close. Shutdown then cascades for free: the generator at the head closes its output, the next stage's `for range` ends, its deferred `close` fires, and so on down to the consumer. Declaring the return type `<-chan Out` is the enforcement rather than a comment: `close` and send are not operations on a receive-only channel, so a downstream stage cannot break the rule by accident.
code
go · 10 linesfunc resize(in <-chan Frame) <-chan Frame {
out := make(chan Frame)
go func() {
defer close(out) // this stage closes only the channel it made
for f := range in {
out <- shrink(f)
}
}()
return out
}go deeper
Be ready to write the stage shape from memory: make the output channel, start one goroutine that ranges over the input, defer the close of the output, return the channel. Say out loud that the sender closes.
Explain why sender-closes is not a style preference: a send on a closed channel panics, and close is a broadcast to receivers. Show how one close at the head cascades all the way down to the consumer's loop.
Show that you enforce ownership with types rather than comments — receive-only returns, make and close in the same function — and that you know what a missing close costs downstream: a range that never returns and a hang with no error.
Own the convention across a codebase: every stage-shaped function returns a receive-only channel and documents who closes it, so reviewers never have to re-derive channel ownership pipeline by pipeline.
## The shape of a stage A Go pipeline is a chain of functions that all share one signature: ```go func stage(in <-chan In) <-chan Out ``` Each stage takes a receive-only input channel, creates its own output channel with `make`, launches exactly one goroutine to do the work, and returns the output channel **immediately** — before any value has been produced. The caller wires the chain by feeding one stage's return value into the next: `encode(resize(decode(paths)))`. Three stages means three live goroutines, each parked at its send or its receive until a neighbour is ready. Three properties fall out of that shape, and they are what an interviewer is checking. ## 1. One channel, one creator, one closer A stage calls `make` exactly once, for its output. That is the only channel it sends on, and the only one it closes. The input belongs to the stage upstream; this stage only receives from it. The rule is not aesthetic — the runtime enforces it with panics: - a send on a closed channel panics with `send on closed channel`; - closing an already-closed channel panics with `close of closed channel`; - closing a nil channel panics. A receiving goroutine has no way to know whether some sender is mid-send, so a receiver can never close safely. A sender always can: it knows when it has sent its last value. Hence "the sender closes". ## 2. close is a broadcast, not a cleanup Closing a channel signals receivers that no more values are coming. Every receive on a closed and drained channel returns immediately with the element type's zero value and `ok == false`, and a `for v := range ch` loop terminates. That is the **only** way a range over a channel ends: not when the channel happens to be empty (an empty open channel blocks the receiver), not on a zero value, and not when the sending goroutine simply returns without closing — that leaves the downstream loop blocked forever. Closing is also not a resource release. A channel is an ordinary heap object; once nothing references it, the collector reclaims it whether or not it was closed. You close a pipeline channel so the downstream `range` terminates, not to free memory. The mirror image is worth knowing too: a channel still referenced by a goroutine blocked on it is never collected, and no amount of closing elsewhere fixes that. ## 3. Shutdown cascades Because every stage closes its own output once its input runs dry, closing the head closes the whole chain: generator's `defer close(out)` → resize's `range` ends → resize's `defer close(out)` → encode's `range` ends → encode's `defer close(out)` → the consumer's `range` ends. Write the close as `defer close(out)` at the **top** of the goroutine rather than as a statement after the loop. Deferred there, it also runs on an early `return` — a cancellation branch, say — and on a recovered panic, so the downstream loop always terminates no matter how this stage exits. ## The type is the enforcement Return `<-chan Out`, not `chan Out`. Direction is part of a channel's type, and `close` and send are simply not operations on a receive-only channel: `close(c)` where `c` is `<-chan Frame` fails to compile. Likewise the parameter is `in <-chan In`, so no stage can close its input by accident and no stage can inject values into another stage's channel. That one typing decision turns the ownership convention from something a reviewer must remember into something the compiler checks. It is why you will see this exact signature in every idiomatic Go pipeline. ## The two mistakes - **Closing the input to be tidy.** It panics the stage upstream, which is still sending — and the stack trace names that upstream stage, not the closer. - **Never closing the output.** The downstream `range` never returns, so the pipeline hangs at the consumer with no panic and no error at all.
- Where exactly should `close(out)` sit inside a stage's goroutine?As `defer close(out)` at the top of the goroutine, above the loop. Deferring it there guarantees it runs on every exit path — the normal end of `range`, an early `return` on cancellation, or a recovered panic — so the downstream `range` always terminates. Calling `close` after the loop only works when the goroutine has exactly one exit.
- What actually ends a stage's `for f := range in` loop?Only the upstream stage closing the channel it sends on. Ranging over a channel keeps receiving until the channel is closed and drained, then exits. It never ends because the channel is momentarily empty, and never on a zero value. If the upstream goroutine dies without closing, this stage blocks in `range` for the life of the process.
- Does a channel have to be closed for its memory to be reclaimed?No. Closing is a signal to receivers, not a deallocation: an unreferenced channel is collected whether or not it was ever closed. You close a pipeline channel because downstream `range` loops need to terminate. What is not collected is a channel still referenced by a goroutine blocked on it — that is a goroutine leak, not a missing close.
saying these in an interview costs you the question
- Says the receiving stage should close its input when it is done
- Closes the output from the caller instead of inside the stage's goroutine
- Thinks a channel must be closed or its memory leaks
- Lets two goroutines close the same channel
- Returns a bidirectional chan so anything downstream can send into it