Why would you set a channel variable to nil inside a Go select loop?
answer
- the cases are fixed at compile time
- but the channel expression is not
- a drained source keeps firing
- assign to the variable the case reads
- loop while any source is still non-nil
basics
~20 sTo switch that select case off. A nil channel is never ready, so assigning nil to the variable a case reads makes that case unselectable until a real channel is assigned back. It is how a drained source is retired.
solid answer
~50 sA `select` has a fixed set of cases at compile time; you cannot delete one at runtime. But each case evaluates a channel *expression*, and a nil channel is never ready, so assigning nil to the variable a case reads disables that case for as long as it stays nil. The classic use is merging several sources: when a receive reports `ok == false` that source is drained, and leaving its case in play spins the loop at full CPU, because a closed channel is always ready. Nilling the variable retires it and lets the loop keep serving the rest. The mirror use gates a send: hold the outbound variable at nil until you have a value to write. The loop then needs its own exit condition, typically running only while some variable is non-nil.
code
go · 20 lines// merge forwards frames from two decoders until both are drained.
func merge(ctrl, data <-chan Frame, out chan<- Frame) {
for ctrl != nil || data != nil {
select {
case f, ok := <-ctrl:
if !ok {
ctrl = nil // disabled: a nil channel is never ready
continue
}
out <- f
case f, ok := <-data:
if !ok {
data = nil
continue
}
out <- f
}
}
close(out)
}go deeper
Know that a select's cases cannot be added or removed while it runs, and that a nil channel is never chosen. That is enough to follow the idiom in code you read.
Explain the whole loop: comma-ok detects the drained source, the variable is set to nil to retire the case, and the for condition ends the loop once every source is nil.
Show why the naive version burns a core, since a closed channel is ready on every iteration, and be ready to apply the same trick on the send side to gate outbound writes.
Weigh it against the alternatives you would let a team ship: a hand-rolled nil-gated select is compact but easy to get wrong, so decide when a simpler fan-in with one goroutine per source is the safer default.
## The problem the idiom solves A `select` statement's cases are fixed when the code is compiled. There is no way to remove a case, and there is no `continue`-style way to skip one: whichever cases are ready, the statement picks from among them. So what do you do when one of the sources a long-lived loop is multiplexing finishes and you want the loop to carry on with the rest? The answer uses the fact that each case names a channel **expression**, re-evaluated every time the `select` runs, and that a nil channel is never ready for either send or receive. Assign nil to the variable a case reads and that case becomes ineligible; assign a real channel back and it becomes eligible again. The `select` is static, but the set of live cases is dynamic. ## The concrete failure it prevents The motivation is not elegance, it is a busy loop. A receive on a **closed** channel is always ready and returns immediately with the zero value and `ok == false`. If a merge loop leaves the case for a finished source in place, that case is ready on every iteration forever, so the loop spins as fast as the scheduler allows, burning a core and starving nothing but delivering nothing either. It is one of the easiest ways to turn a correct-looking pipeline into a 100%-CPU process. So the shape is: detect the drain with the comma-ok form, nil the variable, and continue. ```go case f, ok := <-ctrl: if !ok { ctrl = nil continue } out <- f ``` ## Gating a send The same trick works on the send side and is less well known. Suppose a loop reads frames from a decoder and forwards them downstream, and you want to receive a new frame only when you are not already holding one. Keep two variables: the outbound channel and a pending value. While there is nothing pending, hold the outbound variable at nil so its send case cannot be chosen; once a frame is in hand, set the variable to the real channel so the send becomes eligible. After the send succeeds, nil it again. The loop then alternates naturally between accepting work and delivering it, with no flags and no nested `select`. ## Termination This is where the idiom bites back. Disabling every case does not end the `select` — it makes it unsatisfiable, and a `select` with no ready case and no `default` blocks. A loop written as `for { select { ... } }` will park forever the moment the last source is nil'd, and if nothing else in the process can run, the runtime aborts with a fatal all-goroutines-are-asleep report. The fix is a loop condition that mirrors the disabling: ```go for ctrl != nil || data != nil { select { ... } } ``` With many sources, keep a counter of live inputs and return when it reaches zero. Whichever form you choose, the rule is that *the loop*, not the `select`, is responsible for noticing that there is nothing left to wait for. ## Why not a boolean flag Candidates often propose tracking `ctrlDone` and skipping the case. There is nothing to skip: the case is part of the statement and will still be evaluated and still be ready. Guarding the *body* with a flag does not stop the closed channel from being chosen, so the busy loop survives. The alternatives are genuinely worse: two differently shaped `select` statements chosen by an `if`, or a nested `select` with a `default`. Nilling the variable keeps one statement and one control path. ## Details worth stating - Nilling a **parameter** or local copy does not affect the caller's channel value; you are changing your own variable, not the channel. - It does not close anything and does not free anything by itself. The underlying channel becomes unreachable and collectable only once no other reference remains. - It composes with directional types: a `<-chan T` variable can be set to nil exactly like a bidirectional one, since nil is assignable to every channel type. - Re-enabling is symmetric and legitimate: assigning a fresh channel back into the variable brings the case back, which is how a loop that reconnects a source resumes without restructuring. ## What a strong answer sounds like Name the rule (a nil channel is never ready), name the trigger (comma-ok reporting a drained source, or having nothing to send), name the failure it prevents (a closed channel firing every iteration), and finish with the termination condition, because the interviewer's next question is almost always what happens when they are all nil.
- Why not track a boolean and skip the case body instead?Because there is nothing to skip. The case is still part of the statement and a drained source is still ready, so the select keeps choosing it and the loop keeps spinning; only the body is guarded. Nilling the channel variable removes the case from eligibility, which is what you actually want.
- Can a case that was disabled by nilling be brought back?Yes. The channel expression is re-evaluated each time the select executes, so assigning a live channel back into the variable makes that case eligible again. A loop that reconnects a source uses exactly this, with no change to the select's shape.
- How do you use nil to control a send case rather than a receive case?Hold the outbound channel variable at nil while you have nothing to send, so the send case cannot be chosen, and set it to the real channel once a value is pending. After the send completes, set it back to nil. The loop then alternates between accepting and delivering without flags or nesting.
- Does setting the variable to nil affect the channel the caller passed in?No. You are reassigning your own variable, which holds a copy of the channel value. The caller's channel is untouched, is not closed, and other goroutines holding it are unaffected. Nilling is purely a local statement about which cases this loop still cares about.
saying these in an interview costs you the question
- Thinks assigning nil closes or frees the channel
- Believes a boolean flag around the case body has the same effect
- Assumes the select ends when every case is nil
- Thinks nilling a parameter changes the caller's channel
- Leaves a drained source in the select and calls the CPU spin normal