# Go Channels Select

> Guides Go channel mechanics — declare directionality in signatures (chan less than - send-only, less than -chan receive-only), let only the sender close and only when sends are done (closing is a broadcast, not cleanup), read the comma-ok / range-ends-on-close idiom so a closed channel's zero value is not mistaken for data, choose unbuffered (synchronization) vs buffered (decoupling) deliberately, use chan struct{} for pure signals, and drive select with default for non-blocking and the nil-channel trick to disable a case. Auto-invokes when writing or editing channels, chan declarations, close(), select statements, or buffered channels, and on "send on closed channel panic" / "why does this select block" / "why does my range over a channel never end" requests. The depth behind the policy root's "don't fight Go" for the communication primitive.

- Skill: `ctoth/go-channels-select` (Agent Skill, multi-file: 3 files)
- Install (CLI): `npx skillmds@latest add ctoth/go-channels-select`
- Raw SKILL.md: https://api.skillmd.com/api/skills/ctoth/go-channels-select/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: ctoth (https://skillmd.com/u/ctoth)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/ctoth/go-channels-select

---


# Go Channels and Select

> "Do not communicate by sharing memory; instead, share memory by communicating."
> — [Effective Go](https://go.dev/doc/effective_go)

> "This close is effectively a broadcast signal to the senders."
> — [Go Concurrency Patterns: Pipelines and cancellation](https://go.dev/blog/pipelines)

> "Channels orchestrate; mutexes serialize."
> — [Go Proverbs](https://go-proverbs.github.io/)

A channel is a typed conduit with exact, specified rules: who may send, who may close, what a receive returns after a close, and when an operation blocks. Most channel bugs are not subtle — they are one of those rules violated. This skill owns channel *mechanics*. Goroutine *lifetime* (who starts and stops the goroutine on each end) is owned by `go-concurrency-goroutines`; *cancellation* via `ctx.Done()` is owned by `go-context`; *lock-based* sharing is owned by `go-sync-primitives`.

---

## 1. The Rules, and Where Each Is Stated

Every rule below is a hard fact of the language spec or established guidance, not a preference. The owning section holds the depth.

| Rule | The mechanic | Source |
|---|---|---|
| Declare direction in signatures | `chan<- T` send-only, `<-chan T` receive-only — documents and enforces the role | "A channel may be constrained only to send or only to receive by assignment or explicit conversion" ([Spec — Channel types](https://go.dev/ref/spec#Channel_types)) |
| Only the sender closes | Close says "no more values," from the side that produces them | "stages close their outbound channels when all the send operations are done" ([Pipelines](https://go.dev/blog/pipelines)) |
| Send on closed panics | Never close from a receiver or twice | "A send on a closed channel proceeds by causing a run-time panic" ([Spec — Send](https://go.dev/ref/spec#Send_statements)); "Sending to or closing a closed channel causes a run-time panic" ([Spec — Close](https://go.dev/ref/spec#Close)) |
| Receive from closed is not an error | It yields the zero value with `ok == false` | "A receive operation on a closed channel can always proceed immediately, yielding the element type's zero value" ([Spec — Receive](https://go.dev/ref/spec#Receive_operator)) |
| Unbuffered = rendezvous | Send and receive complete together | "communication succeeds only when both a sender and receiver are ready" ([Spec — Channel types](https://go.dev/ref/spec#Channel_types)) |
| Buffer size 1 or none | Any larger buffer needs justification | "Channels should usually have a size of one or be unbuffered ... Any other size must be subject to a high level of scrutiny" ([Uber](https://github.com/uber-go/guide/blob/master/style.md#channel-size-is-one-or-none)) |
| nil channel blocks forever | Used to *disable* a select case | "a select with only nil channels and no default case blocks forever" ([Spec — Select](https://go.dev/ref/spec#Select_statements)) |
| default makes select non-blocking | Otherwise select blocks until a case is ready | "if there is a default case, that case is chosen" ([Spec — Select](https://go.dev/ref/spec#Select_statements)) |

---

## 2. Declare Channel Direction in Signatures

A bidirectional `chan T` in a parameter says nothing about the function's role. A directional type does: "A channel may be constrained only to send or only to receive by assignment or explicit conversion" ([Spec — Channel types](https://go.dev/ref/spec#Channel_types)) — `chan<- float64` "can only be used to send," `<-chan int` "can only be used to receive" ([Spec — Channel types](https://go.dev/ref/spec#Channel_types)). The compiler then enforces the role, and the signature documents it. A bidirectional channel converts to a directional one implicitly at the call site.

```go
// WRONG — both params are chan T; nothing stops produce from receiving or consume from closing
func produce(ch chan int)  { /* could accidentally <-ch or close from the wrong side */ }
func consume(ch chan int)  { /* could accidentally close(ch) — a receiver must never close */ }

// RIGHT — direction is part of the contract; misuse is a compile error
func produce(out chan<- int) { out <- 1; close(out) } // send-only: can send and close
func consume(in <-chan int)  { for v := range in { use(v) } } // receive-only: cannot close(in)
```

`close(in)` where `in` is `<-chan int` does not compile — "It is an error if ch is a receive-only channel" ([Spec — Close](https://go.dev/ref/spec#Close)) — so directionality turns the "only the sender closes" rule (§3) into a checked guarantee.

---

## 3. Who Closes: The Sender, Once, and Never the Receiver

Closing is **not** cleanup and it is **not** a free()-style "I'm done with this." It is a one-time announcement *to receivers* that no more values are coming: `close(ch)` "records that no more values will be sent on the channel" ([Spec — Close](https://go.dev/ref/spec#Close)). So it belongs to whoever sends: "stages close their outbound channels when all the send operations are done" ([Pipelines](https://go.dev/blog/pipelines)).

Closing is a **broadcast**. Every blocked receiver unblocks at once, because "a receive operation on a closed channel can always proceed immediately" ([Pipelines](https://go.dev/blog/pipelines)); "This close is effectively a broadcast signal to the senders" ([Pipelines](https://go.dev/blog/pipelines)). That is the whole point of the done-channel pattern (§6) — one `close` wakes N goroutines.

Two corollaries, both enforced by the runtime:

- **A receiver must never close.** It does not own the send side, and a later send from the real owner would panic (§4).
- **With multiple senders, no single sender may close** — a second send (or second close) panics: "Sending to or closing a closed channel causes a run-time panic" ([Spec — Close](https://go.dev/ref/spec#Close)). Coordinate shutdown elsewhere (a `sync.WaitGroup`/`errgroup`) and close once after all senders stop — that coordination is goroutine *lifetime*, owned by `go-concurrency-goroutines`.

A channel need not be closed at all: close only to signal "no more values" to a ranging receiver; an unclosed channel is garbage-collected normally.

---

## 4. Send on Closed Panics; Receive From Closed Returns zero, ok=false

These two rules are asymmetric, and the asymmetry is the source of most close-related bugs.

**Sending** on a closed channel is fatal: "A send on a closed channel proceeds by causing a run-time panic" ([Spec — Send](https://go.dev/ref/spec#Send_statements)). There is no recover-and-continue idiom for it; the fix is to never let a send race a close (§3).

**Receiving** from a closed channel is safe and silent — it hands back the zero value. So the bare `v := <-ch` cannot tell "real zero that was sent" from "channel closed." Use the comma-ok form: "The value of ok is true if the value received was delivered by a successful send operation to the channel, or false if it is a zero value generated because the channel is closed and empty" ([Spec — Receive](https://go.dev/ref/spec#Receive_operator)).

```go
// WRONG — once ch is closed, this loops forever handing 0 to process(); the zero is not real data
for {
	v := <-ch          // closed channel returns 0, ok discarded
	process(v)
}

// RIGHT — comma-ok distinguishes a sent value from a close
for {
	v, ok := <-ch
	if !ok {
		break          // channel closed and drained
	}
	process(v)
}

// RIGHT (idiomatic) — range receives until the channel is closed, then stops
for v := range ch {
	process(v)
}
```

`for v := range ch` is the same rule packaged: it pulls values until the channel is closed and drained, then ends. Ranging a channel that is **never** closed blocks forever once the buffer empties — a "why does my range over a channel never end" hang is almost always a missing `close` on the sender side.

---

## 5. Unbuffered Is Synchronization; Buffered Is Decoupling

The capacity argument to `make` is a semantic choice, not a tuning knob.

An **unbuffered** channel is a rendezvous: "communication succeeds only when both a sender and receiver are ready" ([Spec — Channel types](https://go.dev/ref/spec#Channel_types)). The send and the receive happen together, so it also synchronizes: "Unbuffered channels combine communication—the exchange of a value—with synchronization—guaranteeing that two calculations (goroutines) are in a known state" ([Effective Go](https://go.dev/doc/effective_go)). Reach for unbuffered first — it gives you a handoff guarantee.

A **buffered** channel decouples sender from receiver up to the buffer's size: "communication succeeds without blocking if the buffer is not full (sends) or not empty (receives)" ([Spec — Channel types](https://go.dev/ref/spec#Channel_types)). That is useful for a known, bounded count (a semaphore limiting concurrency, or collecting exactly N results), but it removes the handoff guarantee.

The size you pick must mean something: "Channels should usually have a size of one or be unbuffered ... Any other size must be subject to a high level of scrutiny. Consider how the size is determined, what prevents the channel from filling up under load and blocking writers" ([Uber](https://github.com/uber-go/guide/blob/master/style.md#channel-size-is-one-or-none)).

```go
// WRONG — a big buffer chosen to make a deadlock "go away"; it only delays the block and hides backpressure
results := make(chan Result, 1000) // why 1000? what happens at 1001?

// RIGHT — unbuffered: the receiver is guaranteed to have the value before the sender proceeds
results := make(chan Result)

// RIGHT — buffer sized to a real bound: exactly len(jobs) results, never more
results := make(chan Result, len(jobs))
```

A buffer chosen to paper over "send blocks forever" hides a missing receiver — see the deadlock case in `references/common-mistakes.md`.

---

## 6. `chan struct{}` for Pure Signals

When a channel carries *no data* — only the fact that an event happened — its element type should be `struct{}`, which occupies zero bytes. The done/quit pattern is the canonical use: closing it broadcasts "stop" to every receiver (§3). The pipelines blog uses exactly this — `done := make(chan struct{})` — and notes that closing it unblocks all waiters at once ([Pipelines](https://go.dev/blog/pipelines)).

```go
// RIGHT — a signal-only channel; the value never matters, only "did it fire"
done := make(chan struct{})

go func() {
	defer close(done) // broadcast completion to every <-done waiter
	work()
}()

<-done // blocks until work() finishes and close(done) fires
```

Prefer `close(done)` (a broadcast, readable any number of times) over sending one value per waiter (which you would have to count). Use `struct{}` not `bool` or `int`: the type itself says "this is a signal, there is no payload." For cancellation specifically, you usually want `ctx.Done()` rather than a hand-rolled done channel — owned by `go-context`.

---

## 7. `select` Chooses a Ready Case; `default` Makes It Non-Blocking

A `select` "chooses which of a set of possible send or receive operations will proceed" ([Spec — Select](https://go.dev/ref/spec#Select_statements)). The rules are precise:

- **One ready case:** it runs.
- **Several ready cases:** "a single one that can proceed is chosen via a uniform pseudo-random selection" ([Spec — Select](https://go.dev/ref/spec#Select_statements)) — you cannot rely on priority order.
- **No ready case, with a `default`:** "if there is a default case, that case is chosen" ([Spec — Select](https://go.dev/ref/spec#Select_statements)) — the select does not block.
- **No ready case, no `default`:** "the select statement blocks until at least one of the communications can proceed" ([Spec — Select](https://go.dev/ref/spec#Select_statements)).

So `default` is the non-blocking switch. Use it for a "try" operation — but never inside a tight `for` with nothing else, or you get a busy-loop spinning the CPU (see `references/common-mistakes.md`).

```go
// Non-blocking send: drop the value if no receiver is ready, instead of blocking
select {
case ch <- v:
	// delivered
default:
	// nobody ready; skip rather than block
}
```

An empty `select{}` has no cases that can ever proceed, so it blocks the goroutine forever — occasionally used in a `main` that should park while background goroutines run, but usually a sign something is wrong.

---

## 8. The nil-Channel Trick: Disable a `select` Case

A nil channel never communicates: "Receiving from a nil channel blocks forever" ([Spec — Receive](https://go.dev/ref/spec#Receive_operator)) and "A send on a nil channel blocks forever" ([Spec — Send](https://go.dev/ref/spec#Send_statements)). In a `select`, a case whose channel is nil can never be chosen — "communication on nil channels can never proceed" ([Spec — Select](https://go.dev/ref/spec#Select_statements)). Setting a channel variable to `nil` therefore *removes* its case from the select, dynamically, without restructuring the loop.

The classic use: when an input channel is drained (closed), set it to `nil` so the loop stops selecting on it but keeps serving the other cases.

```go
// RIGHT — once `in` is closed, nil it out so this select stops spinning on the always-ready closed case
func merge(in <-chan int, out chan<- int, done <-chan struct{}) {
	for in != nil {
		select {
		case v, ok := <-in:
			if !ok {
				in = nil // disable this case; a closed channel is "always ready" and would busy-loop
				continue
			}
			out <- v
		case <-done:
			return
		}
	}
}
```

Without the `in = nil`, a closed `in` is permanently ready and `<-in` keeps firing with the zero value — the busy-loop from §4 wearing a select. Niling the case is the idiomatic disable.

---

## 9. `select` for Timeout and Cancellation

`select` composes a channel operation with an escape hatch. The two escape hatches are a timer and a context:

```go
// Timeout: race the work against a deadline
select {
case v := <-work:
	use(v)
case <-time.After(2 * time.Second):
	return errTimeout
}

// Cancellation: race the work against the caller giving up
select {
case v := <-work:
	use(v)
case <-ctx.Done():
	return ctx.Err()
}
```

Prefer `ctx.Done()` for anything request-scoped or cancellable — it propagates and composes — and reserve `time.After` for a genuine local timeout. The `ctx.Done()` channel, `ctx.Err()`, and the `context.Canceled` / `context.DeadlineExceeded` values are owned by `go-context`; this skill owns only the `select` *mechanic* that consumes them. (A `time.After` in a hot loop allocates a timer per iteration that lives until it fires — for a repeated timeout reset a `time.Timer`, owned by `go-time`.)

---

## 10. Who Suffers When Channels Are Done Badly

The victim is never the author at write time:

- The **on-call engineer** paged at 3am by a deadlock: every goroutine blocked sending to a channel whose only receiver returned early, the process wedged with no error and no log.
- The **teammate** chasing a worker that "sometimes" processes a phantom zero record — a `v := <-ch` that lost the `, ok` and read a closed channel's zero value as data (§4).
- The **whole service**, crashed by a `close of closed channel` panic because shutdown let two senders both close — a rule the spec makes fatal precisely so it cannot be ignored ([Spec — Close](https://go.dev/ref/spec#Close)).

"Channels orchestrate; mutexes serialize" ([Go Proverbs](https://go-proverbs.github.io/)) is also a warning: a channel reached for where a `sync.Mutex` would do (guarding one shared field) buys a concurrency primitive's full failure surface — deadlocks, leaks, panics — to do a lock's job. When the answer is "protect this state," not "hand off this value," that is `go-sync-primitives`.

---

## 11. Routing to Related Skills

- `go-idiomatic-discipline` — the policy root; this skill is the channel-mechanics depth behind its "don't fight Go."
- `go-concurrency-goroutines` — **who owns the channel**: goroutine lifetime, who starts/stops each end, closing once after N senders stop, `WaitGroup`/`errgroup`. The §3 coordination lives here.
- `go-context` — `ctx.Done()` as the cancellation channel in §9, and `context.Canceled`/`DeadlineExceeded` as values.
- `go-sync-primitives` — when a `Mutex` beats a channel (§10): guarding shared state vs handing off values.
- `go-race-and-memory-model` — why a channel send/receive establishes happens-before, and how to detect the races a misused channel leaves behind (`go test -race`).
- `go-iterators-rangefunc` — the channel-vs-iterator choice for producing a sequence.
- `go-time` — `time.Timer`/`Ticker` lifetime behind the §9 timeout.

---

## 12. Reference Files

High-frequency channel and select anti-patterns in LLM-generated Go, each with wrong/right code and citations:

[references/common-mistakes.md](references/common-mistakes.md)

Source provenance for every claim in this skill:

[references/sources.yaml](references/sources.yaml)

