Why does a select with a receive case and a default never pick default once the channel is closed?
answer
- closing does not make receives block
- what a receive yields after close
- always-ready cases starve the escape hatch
- the single-value receive hides the signal
- assigning nil is how you retire a case
basics
~20 sA receive on a closed channel is always ready: it returns the element type's zero value with ok false, without waiting. Since the receive case can always proceed, the select takes it every time and default becomes unreachable.
solid answer
~50 s`default` runs only when no case can proceed, and a receive from a closed channel can always proceed — it completes immediately, yielding the zero value and `ok == false`. So the moment the channel is closed, the receive case wins on every pass and `default` is dead code. In a `for` loop that try-receives and processes whatever it gets, that turns into an infinite full-speed loop handling zero values: a hot core plus corrupt work. The fix is to use the two-value form, `case v, ok := <-ch:`, and treat `ok == false` as "closed, stop reading this channel" — either by returning, or by setting the channel variable to nil, since a receive on a nil channel is never ready and permanently disables that case. This is also why `default` cannot be used to detect an empty channel: empty-and-open and closed look completely different to a select.
code
go · 12 linesfor {
select {
case v, ok := <-work:
if !ok {
work = nil // a nil channel is never ready: case disabled
break
}
handle(v)
default:
// work is open but empty right now
}
}go deeper
Remember the plain fact: after close, a receive returns immediately with the zero value and ok false. It does not block and it does not panic.
An interviewer expects you to connect that fact to select's readiness rule and predict the resulting infinite loop, then fix it with the two-value receive form.
Show how you would recognise this in production: a pinned core plus zero-valued records reaching downstream systems, and why nil-ing the channel is the clean way to retire one input of a multi-way select.
Argue for the convention that keeps this out of the codebase — who is allowed to close a channel, and a review rule that every receive whose channel can close uses the two-value form.
## The readiness rule, applied to a closed channel A `select` chooses among cases that can proceed *now*, and falls to `default` only when none can. So the question is always "is this receive ready?" For a receive, ready means one of three things: a sender is parked on the channel, the buffer holds a value, **or the channel is closed**. That third clause is the whole answer. Closing a channel does not make receives fail or block; it makes them *always succeed instantly*, producing the element type's zero value with the two-value form's `ok` set to `false`. A closed channel is therefore permanently ready, and a select containing a receive on it will pick that case every single time. `default` is unreachable from that moment on. ```go ch := make(chan int) close(ch) select { case v, ok := <-ch: fmt.Println(v, ok) // prints: 0 false default: fmt.Println("nothing ready") // never runs } ``` ## The bug this creates The damage shows up in a loop that was written as "pick up any pending work, otherwise do something else": ```go for { select { case v := <-work: handle(v) // after close: handle(0), forever default: // intended: idle path } } ``` Before the close this behaves as intended. After the close it becomes an infinite, full-throttle loop calling `handle` with zero values — a zero-valued struct, an empty string, a nil pointer. You get a pinned CPU core *and* a stream of bogus work items, which is worse than either alone, because the bogus items may be written to a database or published downstream. And it is not a crash: nothing panics, so the only symptom is a core at 100% and garbage in the output. ## Detecting the close properly The single-value receive form throws away the information you need. Use the two-value form inside the case: ```go select { case v, ok := <-work: if !ok { work = nil // disable this case permanently break } handle(v) default: // open, but nothing pending } ``` Two idiomatic exits from `!ok`: - **Return** from the loop, when a closed input means the goroutine's job is over. Simplest, and usually right. - **Set the channel variable to nil.** A receive on a nil channel is *never* ready, so that case can never be chosen again while the select keeps waiting on its other cases. This is the standard way to retire one input of a multi-way select while the rest keep running. ## The distinction `default` cannot express A select with `default` gives you two observable outcomes: "the receive happened" and "nothing was ready". Landing in `default` means the channel is **open and empty**. Landing in the case with `ok == false` means the channel is **closed and drained**. These are different states with different responses — the first is transient and you should try again later, the second is terminal and you must stop reading. Code that only writes `case v := <-ch:` cannot tell them apart and will treat a permanently finished channel as an endless supply of zero values. Also worth stating for completeness: closing affects *sends* in the opposite direction. A send case on a closed channel is also "ready", and choosing it panics with "send on closed channel". So a `select` with a send case and a `default` does not protect you from a closed channel either — `default` is not an error handler. ## Summary of the readiness table For a receive case in a select: open and empty → not ready, so `default` runs. Open with a value or a waiting sender → ready. Closed → always ready, zero value, `ok` false. Nil channel → never ready, so `default` always runs. Those four rows explain every surprising thing a `select`/`default` pair does.
- What are the observable symptoms of that loop after the channel is closed?One CPU core pinned at 100 percent by a single goroutine, plus a flood of zero-valued items flowing through the handler — empty strings, zero ids, nil-field structs. Nothing panics and no error is returned, so the corruption is often noticed downstream long before the spin is.
- Why set the channel variable to nil instead of adding a boolean flag?A receive on a nil channel is never ready, so the runtime itself excludes the case; the select simply waits on the remaining cases. A flag would still leave the always-ready closed channel in the select, so the case would keep being chosen and you would need to skip it in the body every pass.
- Does a send case with a default protect you from sending on a closed channel?No. A send on a closed channel counts as ready, so the select chooses that case and the send panics with "send on closed channel". `default` only covers the not-ready situation; it is not an error handler, and the panic is unaffected by its presence.
- How do you distinguish an empty open channel from a closed one in a try-receive?Landing in `default` means open and empty — transient, try again later. Landing in the case with the two-value form and `ok == false` means closed and drained — terminal, stop reading. Only the two-value form makes the second case visible.
saying these in an interview costs you the question
- Believes a receive on a closed channel blocks
- Thinks default runs once the channel is closed
- Expects a receive after close to panic
- Uses default to test whether a channel is empty
- Ignores ok and processes the zero values