Mahdi.Kh
September 21, 2026
We get familiar with channels and learn how goroutines can communicate with each other using channels.
In Go, goroutines can pass values to each other through channels. A channel is like a pipe: one goroutine can put something into it, and another goroutine can receive it from the other end.
Every channel has a data type that determines what kind of data it can carry. To declare a channel, we can use chan together with the data type that should be transferred:
We use the <- operator to send and receive data on a channel. When it points toward the channel, a message is sent to the channel:
And when it points away from the channel, a message is received from the channel:
When a goroutine wants to send a value to a channel or receive a value from it, execution stops — or blocks — at that point until the operation completes. During a send (messages <- "ping"), the sending goroutine waits until another goroutine receives that value from the channel.
During a receive (<-messages), the receiving goroutine waits until a value becomes available to read from the channel. In other words, a channel is the point where goroutines connect to each other, and their execution stops until the operation is complete.
This is exactly the concept of blocking during sends and receives. A send and a receive on a channel are really a pair of synchronized operations. A send isn't complete without a matching receive (and vice versa), and only once the receive happens can both goroutines continue their execution.
When goroutines communicate with each other through a channel, one important challenge is how to signal that the communication has ended. There are three common approaches, each with its own advantages and drawbacks.
In Go, every data type has a zero value. This value is assigned to a variable when we declare it, for example:
One way to signal the end of communication is to send the zero value to the channel.
This approach has advantages and drawbacks compared to the other approaches we'll look at next.
Because of this high risk, this approach isn't commonly used.
In this approach, we use a special value, such as -1 or END, to signal that communication has ended:
This approach also isn't used when the sole goal is signaling the end of communication. The standard way to signal the end of communication is to close the channel, which we'll look at next.
In Go, the best way to signal the end of communication is to close the channel.
After a channel is closed in Go, if the channel is empty, every read from it returns the default value (zero value); to tell whether this value is real data or the result of the channel being closed, you can use the second return value (ok), which is boolean, and when it's false it means the channel is closed and the received value isn't real data.
Next, we'll look at the behavior and details of channels once they're closed.
Reading from a channel means receiving data that was sent by another goroutine, and by default this operation is blocking; that is, if there's no data available on the channel, the goroutine waits until a value is sent.
You can receive the data along with a second value that indicates whether the channel is open or closed.
Using range on a channel is a common, readable way to keep receiving data until the channel is closed.
Channels in Go are bi-directional by default, meaning they support both sending and receiving data. Channels can also be declared so that they're used only for sending or only for receiving data.
A send-only channel and a receive-only channel look like this:
Using uni-directional channels, we can rewrite our earlier example as follows:
Uni-directional channels (send-only or receive-only) have advantages and drawbacks.
The Go compiler prevents misuse; for example, if a channel is declared as receive-only, it won't be possible to send data to it.
The channel's type clearly states each goroutine's role (send-only or receive-only).
Many concurrency mistakes, such as an unintended send or misuse of a channel, are caught at compile time.
When a function takes only a <-chan as input, it's guaranteed to be purely a consumer and has no part in producing or altering the data flow.
Uni-directional channels are less flexible, and you can't use them for both sending and receiving, even if the need changes later.
In small projects, they can make function signatures more complex and reduce initial readability.
In some cases you need to convert between bi-directional and uni-directional channels, which can make the code a bit more complex.
In scenarios with multiple senders or receivers, restricting the channel's direction can make the design harder.
Buffered channels in Go are a kind of channel that can hold a number of values without needing them to be received immediately; unlike regular (unbuffered) channels, where a send and a receive must happen at the same time. Buffered channels behave like a queue with a fixed capacity.
As we can see in the example above, there's no difference in how the channel is declared; it's only at the point of allocating memory that we can specify whether a channel is buffered or not.
A buffered channel behaves as follows:
How do we know when we should use buffered channels?
Usually, you can write the code first without buffered channels, and later turn the channel into a buffered one if needed.
You can close a channel using the close function; closing a channel is like a one-way signal announcing that no more data will be sent. This is mostly used to coordinate the end of work between goroutines, not to free up memory!
It's always better to leave the job of closing the channel to the goroutine that's sending the data to it. That way, the channel can be closed once the sending is done.
nil ChannelsA channel is a reference type. This means that after declaring a channel, if we don't allocate memory to it using the make function, it will hold the value nil! In other words, the zero value of a channel is nil.
In the example above, we declared a channel but didn't allocate memory to it. So the value nil is shown in the output.
If a channel is nil, sending to it or receiving from it will block forever! Also, if we close a nil channel, we'll get a panic.
By combining a nil channel with a select statement, you can control how goroutines execute.
The code above runs for two milliseconds, and during those two milliseconds, values are read from channel 1 and channel 2 alternately and printed. In the code above, by setting channels to nil, we set up the program's control flow so that data is read from ch1 and ch2 alternately.
Previous part: Goroutines | Golang basics
Next part: Context | Golang basics