Channels are Go's primary mechanism for communication between goroutines. The core philosophy — do not communicate by sharing memory; share memory by communicating — means that instead of protecting shared variables with mutexes, you pass ownership of data from one goroutine to another through a channel. When a value is sent on a channel, the sender relinquishes access, and the receiver takes ownership. Data races become structurally impossible when this discipline is followed.
A channel is a typed conduit created with make(chan T). Sends block until a receiver is ready (unbuffered) or until buffer space is available (buffered). Receives block until a value is available. This blocking behavior is not a limitation — it is the synchronization mechanism. Combined with the select statement for multiplexing, range for iteration, and close for signaling completion, channels enable expressive concurrency patterns: pipelines that chain processing stages, fan-out that distributes work across workers, fan-in that merges results, and cancellation that propagates through an entire call graph.
Channels are reference types backed by a runtime data structure that includes a mutex, a circular buffer (for buffered channels), and wait queues for blocked senders and receivers. They are safe for concurrent use without additional synchronization. Directional channel types (chan<- for send-only, <-chan for receive-only) encode intent in function signatures, making APIs self-documenting and preventing misuse at compile time.
Master go channels
Take the Go Programming course with hands-on lessons and challenges.
An unbuffered channel forces a rendezvous: the sender blocks until a receiver is ready, creating a synchronization point. A buffered channel with capacity 2 lets the sender deposit up to 2 values without blocking. Choose unbuffered when you need synchronization guarantees, buffered when you need to decouple producer and consumer speeds.
The select statement waits on multiple channel operations simultaneously and executes whichever case becomes ready first. Here it receives from fast and slow concurrently, with a context timeout as a deadline. If the slow result does not arrive before the timeout, the ctx.Done() case fires and the function returns without leaking the loop.
Each stage is a goroutine that reads from an inbound channel and writes to an outbound channel. Stages compose by chaining return values. Closing the outbound channel signals downstream stages that no more data is coming, so range loops terminate naturally. This is the foundational pattern described in the Go blog's pipeline article.
Fan-out distributes work by having multiple goroutines read from the same channel — the runtime ensures each value goes to exactly one reader. Fan-in collects results by merging multiple output channels into one using a WaitGroup to know when all workers are done. The merged channel closes only after every input channel is drained.
Directional channel types enforce at compile time that a function can only send or only receive. The bidirectional channel created in main is implicitly narrowed when passed to produce (send-only) and consume (receive-only). This prevents bugs where a producer accidentally reads from its output channel or a consumer accidentally writes to its input.
A buffered channel of empty structs acts as a counting semaphore. Sending to it acquires a slot (blocking when full), receiving releases a slot. This limits concurrency to maxConcurrent goroutines at any time, regardless of how many were launched. The empty struct{} costs zero bytes of allocation.
Sending on a closed channel — this causes an unrecoverable panic at runtime, and it can be hard to trace when multiple goroutines share a channel
Only the sender should close a channel, and only when all sends are complete. If multiple goroutines send on the same channel, use a sync.WaitGroup to close it after all senders finish. Never close from the receiver side.
Forgetting to close a channel that a consumer ranges over — the range loop blocks forever waiting for more values, causing a goroutine leak and potential deadlock
Every channel that a goroutine ranges over must eventually be closed by the sender. Structure your code so the producer goroutine defers close(ch) immediately after creating the channel.
Using a buffered channel to avoid synchronization issues instead of addressing the underlying design problem — large buffers mask race conditions and timing bugs that surface under load
Choose buffer sizes based on the semantics you need, not as a band-aid. Buffer size 0 (unbuffered) for synchronization guarantees, small buffers (1-10) to absorb bursts, and larger buffers only when you have measured throughput requirements.
Reading from a nil channel — this blocks forever with no panic or error, silently stalling the goroutine
A nil channel is useful in select statements to disable a case dynamically (set the channel variable to nil after it is closed to stop selecting on it). Outside of select, ensure channels are always initialized with make().
Channels are Go's typed communication primitives for goroutines. Unbuffered channels synchronize sender and receiver; buffered channels decouple them up to a capacity. The select statement multiplexes across channels, directional types enforce send/receive roles at compile time, and closing a channel broadcasts completion to all readers. Combined into patterns — pipelines, fan-out/fan-in, semaphores — channels let you build concurrent systems where data flows through well-defined paths without shared mutable state.
Interactive lessons and challenges on Stanza, practice in VS Code, Cursor, or the web.
Interactive lessons and challenges, right in your code editor.
Check the free courses. No credit card.