skip to content

Why does a select with a receive case and a default never pick default once the channel is closed?

level: middleimportance: nice to knowfreq 40%

answer

  1. closing does not make receives block
  2. what a receive yields after close
  3. always-ready cases starve the escape hatch
  4. the single-value receive hides the signal
  5. assigning nil is how you retire a case

basics

~20 s

A 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 lines
go
for {
	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

for a junior

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.

for a middle

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.

for a senior

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.

for a principal

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