Channel Direction and Ownership
Go’s channel type has an optional directional qualifier —
chan<- Tfor send-only and<-chan Tfor receive-only — that the language spec calls the channel’s direction. A plainchan Tis bidirectional; a bidirectional channel may be implicitly converted to either directional form, but a directional channel can never be widened back. This single type-system feature, combined with one informal discipline — whoever owns a channel closes it, and that owner is always the sole sender — is what keeps real concurrent Go programs from the two classic channel bugs: panicking by sending on (or closing) a closed channel, and deadlocking on a channel nobody will ever close. Direction is enforced by the compiler; ownership is a convention you enforce yourself, and directional types are the tool that makes the convention visible in every function signature.
Mental Model
Think of a channel as a one-way pipe with two distinct roles attached to it: the producer end, where values are pushed in, and the consumer end, where values are pulled out. A bidirectional chan T value is a handle that grants both roles. A directional channel value is the same underlying pipe, but the handle has been deliberately narrowed so the holder can perform only one role. chan<- T (read “channel-of, send into”) keeps only the producer capability; <-chan T (read “receive-from channel”) keeps only the consumer capability.
The crucial mental shift is that direction is a property of the channel variable’s type, not of the channel itself. The runtime object created by make(chan int) has no notion of direction — len, cap, close, sends, and receives all work on it. Direction lives entirely in the static type of whatever variable, parameter, or struct field is pointing at that object. Two variables of types chan<- int and <-chan int can refer to the same runtime channel; one can only send, the other can only receive, and the compiler enforces that asymmetry without any runtime cost. Directional channels are purely a compile-time contract — they generate identical machine code to bidirectional ones.
Ownership is the social half of the model. A channel has exactly one owner: the goroutine (or, more loosely, the bit of code) that created it with make, is responsible for all sends on it, and — when sends are finished — closes it. Everyone else is a consumer: they receive, they may range over it, but they never send and never close. The spec rule that “sends on a closed channel panic” and that a second close of an already-closed channel panics (per builtin.close) means that any deviation from single-owner discipline is a latent crash. Directional types are how you make the owner/consumer split structural instead of conventional: a function that takes a <-chan T cannot close it (close requires a channel you can send on) and cannot send on it — the compiler has removed the footguns.
graph LR subgraph Owner["Owner goroutine — holds chan T"] M["make(chan int)"] --> S["sends values"] S --> C["close(ch) when done"] end subgraph Pipe["one runtime channel object"] H[("hchan: buf, sendq, recvq, closed flag")] end subgraph Consumers["Consumer goroutines — hold <-chan T"] R1["range / <-ch"] R2["range / <-ch"] end M -. "creates" .-> H S -. "chan<- view" .-> H C -. "chan<- view" .-> H H -. "<-chan view" .-> R1 H -. "<-chan view" .-> R2
Diagram: one runtime channel, three roles. The owner holds a full chan T (or passes a chan<- T to a producer it spawns) and is the only code that sends or closes; consumers receive through <-chan T views. The insight: direction narrows the handle, not the pipe — and the close arrow originates from exactly one place, which is what prevents the double-close panic.
Mechanical Walk-through
The grammar of direction
The spec’s channel-type production is ChannelType = ( "chan" | "chan" "<-" | "<-" "chan" ) ElementType (Channel types). The arrow is glued to the chan keyword: chan<- for send, <-chan for receive. There is a deliberate parsing rule — <- associates with the leftmost chan possible — so a nested type like chan<- <-chan int parses as chan<- (<-chan int): a send-only channel whose elements are themselves receive-only channels. When in doubt, parenthesize: chan (<-chan int) is unambiguous.
Implicit narrowing, never widening
Direction interacts with the assignability rules. The relevant clause: a value x of channel type V is assignable to a variable of channel type T if V and T “have identical element types, V is a bidirectional channel, and at least one of V or T is not a named type.” Read carefully: the conversion is allowed only when the source is bidirectional. You can assign a chan int to a chan<- int variable or a <-chan int variable, and you can pass it to a parameter of either directional type. You cannot assign a chan<- int to a <-chan int, nor a directional channel of either kind back to a chan int. Direction is a one-way ratchet: once a handle is narrowed, the lost capability is gone for that handle.
This is why the idiomatic pipeline-stage signature works. A function declared func stage(in <-chan int) <-chan int receives a receive-only parameter. At the call site you pass an ordinary chan int — the bidirectional-to-directional conversion is implicit and free. Inside the function the parameter’s type forbids sending on or closing in, which is exactly correct: in is owned by an upstream stage. The function creates its own out channel as a plain chan int, owns it, sends on it, closes it, and returns it — and the return type <-chan int narrows the handle the caller receives, so the caller cannot accidentally close a channel it does not own.
Explicit conversion
You can also convert explicitly: co := (chan<- int)(ch). The spec’s conversion rules permit converting a bidirectional channel to a directional one with the same element type. Explicit conversion buys nothing over implicit assignment for the common case; it is occasionally useful to pin a type in a composite literal or to document intent. Note the same restriction holds — you cannot convert a directional channel to one with more capability.
Ownership and close
close(ch) requires ch to be a channel you can send on — its parameter type is chan T or chan<- T; you cannot close a <-chan T. This is the compiler-level half of the ownership rule. The convention layered on top, articulated in the Pipelines blog post: “stages close their outbound channels when all the send operations are done” and “stages keep receiving values from inbound channels until those channels are closed.” In other words: the sender closes; the receiver never closes. A closed channel is a broadcast: every pending and future receive returns immediately with the element’s zero value and ok == false in the v, ok := <-ch form. Closing is therefore the canonical way to signal “no more values” to an unknown number of consumers at once.
The asymmetry that makes the discipline mandatory: receiving from a closed channel is always safe; sending 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 with close of nil channel. So if two goroutines both think they own a channel, you have a guaranteed crash the moment they race to close. Single ownership eliminates the race by construction.
Code Examples
A pipeline stage with directional signatures
// generator owns 'out': it makes it, sends on it, and closes it.
func generator(nums ...int) <-chan int { // 1
out := make(chan int) // 2
go func() {
defer close(out) // 3
for _, n := range nums {
out <- n // 4
}
}()
return out // 5
}
// square consumes 'in' (receive-only) and owns a fresh 'out'.
func square(in <-chan int) <-chan int { // 6
out := make(chan int)
go func() {
defer close(out) // 7
for n := range in { // 8
out <- n * n
}
}()
return out
}
func main() {
for n := range square(generator(2, 3, 4)) { // 9
fmt.Println(n) // 4, 9, 16
}
}Line 1: the return type is <-chan int — the caller gets a receive-only handle, so it physically cannot close a channel generator owns. Line 2: out is created as bidirectional chan int because this function needs to send on it. Line 3: defer close(out) runs when the goroutine’s anonymous function returns — the owner closes, exactly once, after the last send. Line 4: the send is legal because out is bidirectional here. Line 5: returning out (type chan int) where the signature says <-chan int triggers the implicit narrowing conversion. Line 6: in is declared <-chan int; inside square you cannot write in <- x or close(in) — both fail to compile, which is the point: in belongs to generator. Line 7: square owns and closes its own out. Line 8: for n := range in drains in until its owner closes it — the range loop ends automatically on close. Line 9: main ranges over a <-chan int and never closes anything.
What the compiler rejects
func leak(out chan<- int) {
close(out) // OK: close needs a sendable channel; chan<- qualifies
v := <-out // COMPILE ERROR: receive from send-only channel out
_ = v
}
func consume(in <-chan int) {
in <- 1 // COMPILE ERROR: send to receive-only channel in
close(in) // COMPILE ERROR: close of receive-only channel in
}
func widen(c <-chan int) chan int {
return c // COMPILE ERROR: cannot use c (<-chan int) as chan int
}Each error is detected at compile time with zero runtime cost. The close(out) on a chan<- int is allowed — close only needs the send capability — which is why a producer goroutine handed a chan<- T can still close it. That is intentional: it lets you delegate ownership of the send-and-close role to a spawned goroutine while keeping the consumer side <-chan T.
Delegating ownership explicitly
// orchestrator owns the channel but delegates send+close to a worker.
func run() <-chan Result {
results := make(chan Result) // owner: this function
go produce(results) // worker gets chan<- Result implicitly
return results // caller gets <-chan Result
}
func produce(out chan<- Result) { // send-only: cannot receive, can close
defer close(out) // delegated close is fine
for _, job := range jobs {
out <- process(job)
}
}The bidirectional results is created once. produce receives a chan<- Result view — it can send and close but not receive — and run’s caller receives a <-chan Result view. Three handles, three capability sets, one runtime channel, exactly one closer.
Failure Modes and Common Misunderstandings
Sending on a closed channel — panic: send on closed channel. The single most common channel crash. It happens when more than one goroutine sends on a channel and one of them closes it while another is mid-send. The fix is structural: a channel has one owner; only the owner sends; only the owner closes. If you genuinely have multiple producers, do not have any of them close — instead use a sync.WaitGroup (see sync.WaitGroup Internals): each producer wg.Done()s when finished, and a separate closer goroutine does wg.Wait(); close(ch). Now there is still exactly one closer.
Double close — panic: close of closed channel. Two goroutines both believe they own the channel, or a close runs twice on a retry path. Directional types help (<-chan consumers cannot close), but two chan<- T holders can still both close. The fan-in/WaitGroup-closer pattern above is the canonical fix. For the special case of “close at most once from racy callers,” sync.Once wrapping the close works but is usually a sign the ownership model is muddled.
Closing to signal the producer to stop — wrong direction. A frequent confusion: people try to close a channel to tell the sender to quit. But closing is a receiver-facing signal, and the sender is the closer, so this is incoherent. To cancel a producer, you need a separate signal it receives — historically a done := make(chan struct{}) that the producer selects on, today a context.Context (see The Context Package and Graceful Shutdown Patterns). Closing the data channel and cancelling the producer are two different mechanisms.
Forgetting to close — fatal error: all goroutines are asleep - deadlock! or a leaked goroutine. A for v := range ch loop never terminates until ch is closed. If the owner exits without closing, every ranging consumer blocks forever. With directional types this is easy to audit: find the one function that holds the bidirectional/chan<- handle and confirm it defer close()s.
Believing direction has runtime cost or runtime meaning. It has neither. Direction is erased after type-checking; chan<- int and <-chan int and chan int all compile to operations on the identical hchan struct (see Channel Internals). You cannot ask a channel “what is your direction” at runtime — direction is not stored anywhere. Reflection (reflect.ChanDir) reports the direction of a channel type, not of the runtime object.
nil directional channels. A nil channel of any direction blocks forever on send and receive. This is occasionally useful — setting a channel variable to nil disables that case in a select (see The select Statement) — but a nil channel that you forgot to make is a silent deadlock.
Alternatives and When to Choose Them
Bidirectional chan T everywhere. Legal and sometimes simpler for small, single-file programs where one goroutine owns and uses a channel locally. The cost is lost documentation and lost compiler enforcement: any reader of a func(ch chan T) signature must guess whether the function sends, receives, or closes. Prefer directional parameters the moment a channel crosses a function boundary — the signature becomes self-documenting.
context.Context for cancellation instead of a done channel. When the signal you need is “stop everything,” a context.Context is the modern, composable choice and is covered in The Context Package and Graceful Shutdown Patterns. Directional data channels and a cancellation context are complementary, not alternatives — a well-built pipeline uses both: <-chan/chan<- for the data flow, ctx for the kill switch.
errgroup.Group (see errgroup and Structured Concurrency) packages the multi-producer + single-closer + error-propagation pattern; reach for it instead of hand-rolling WaitGroup + closer goroutine when stages can fail.
Sharing memory with a sync.Mutex instead of a channel. The orthogonal choice. Go’s proverb — “Do not communicate by sharing memory; instead, share memory by communicating” (Effective Go) — favors channels for handing off ownership of data, but a mutex-guarded struct is simpler for shared mutable state with no handoff. Channels (and their ownership discipline) shine for pipelines and producer/consumer; mutexes shine for caches and counters.
Production Notes
In production Go codebases the directional-channel convention is near-universal in any function signature that takes or returns a channel — the Go standard library itself models it (time.Tick returns <-chan Time; context.Context.Done() returns <-chan struct{} precisely so a child cannot cancel its parent — see The Context Package). When you read an unfamiliar concurrent package, the first thing the channel directions in its exported signatures tell you is the data-flow topology: a <-chan return is “I produce, you consume,” a chan<- parameter is “you produce into what I gave you.”
The ownership rule is the one most often violated under deadline pressure, and the resulting send on closed channel panic is notoriously hard to reproduce because it is timing-dependent. The race detector (see Data Races and the Race Detector) does not catch it — a close/send race on a channel is not a data race in the memory-model sense; it is a logic bug. The defenses are entirely structural: enforce single ownership, push close to a defer at the top of the owning goroutine so it is impossible to forget and impossible to run twice, and use <-chan return types so callers cannot close what they receive. Go 1.26’s experimental goroutine-leak profiler (GOEXPERIMENT=goroutineleakprofile, see Go 1.26 Release Notes and Goroutine Leaks) will surface goroutines permanently blocked on a channel that was never closed — a direct, post-hoc check on whether the ownership discipline held.
A practical audit heuristic for code review: for every make(chan ...), find the matching close(...); confirm there is exactly one, that it lives in the same goroutine that does the sends, and that it is deferred. For every chan<- T parameter, confirm the receiving function is genuinely a producer. For every <-chan T, confirm nobody tries to close it. If those three checks pass, the two channel panics are unreachable.
See Also
- Channels in Go — channel semantics: send, receive, buffered vs unbuffered,
close - Channel Internals — the
hchanruntime struct; direction is erased before this level - The select Statement —
nildirectional channels disable aselectcase - Pipeline Pattern — the canonical use of directional channels stage-to-stage
- Fan-Out Fan-In Pattern — multiple producers and the
WaitGroup-closer idiom - Worker Pool Pattern — directional channels for job/result queues
- Graceful Shutdown Patterns — cancellation as the complement to directional data flow
- The Context Package —
Done()returns<-chan struct{}to enforce parent/child asymmetry - errgroup and Structured Concurrency — packaged multi-producer + single-closer
- Goroutine Leaks — a never-closed channel leaves consumers blocked forever
- Buffered vs Unbuffered Channel Mistakes — related gotchas
- Go Internals MOC — Section 10, Concurrency Patterns and Bugs