1. Closing a channel: close(ch)
When you write single-threaded code, the “end of data” is often obvious by itself: for example, you reached the end of an array, or the loop for i := 0; i < n; i++ has finished. But in concurrent code, data can arrive “from the future”: it’s not there now, but it will appear in 5 milliseconds. And that’s where philosophy at the level of “waiting as a lifestyle” begins—if you don’t agree on when the data is definitely over, the receiver can wait forever.
Imagine you have a producer goroutine that sends results into a channel. Another consumer goroutine reads those results and prints them. Everything is fine while results exist. But when they run out, the consumer needs some way to understand that this is not “temporary silence” but “the movie is over, time to go home.”
A bad (but very common for beginners) idea is to send a “special value,” for example 0, as an end marker. This breaks instantly as soon as 0 becomes a legal data value. That’s why channels have a built-in official mechanism: closing the channel.
What close does (and what it does not do)
It’s important to start with honesty: close(ch) is not “destroy the channel” and not “kill a goroutine.” In Go, closing a channel is a protocol signal meaning: “No more values will be sent on this channel.”
A spec-style wording is roughly: the built-in function close(ch) for channel ch “records that no more values will be sent on the channel” (records that no more values will be sent).
What close does not do:
- It does not clear the buffer “by magic.” If a buffered channel already contains values, they remain there and will be read as usual.
- It does not “stop” goroutines. If you have a goroutine that (in the human sense) is panicking and spinning forever in a loop, close will not calm it down.
- It is not a “command to exit” for the receiver. The receiver decides on its own what to do after observing the close: end the loop, finish a report, close files, make coffee.
A mini example just to see the syntax and that reading after close is possible at all:
package main
import "fmt"
func main() {
ch := make(chan int)
close(ch)
v, ok := <-ch
fmt.Println(v, ok) // 0 false
}
Here v became 0, and ok became false. This is the main tool we’re going to master today.
Who should close the channel
Channel closing comes with one rule that saves nerve cells better than a vacation: the sender closes the channel (or whoever owns the sending side). The reason is simple: only the sender knows for sure that it will not send anything else. The receiver cannot know that—it only sees “empty right now.”
If the receiver closes the channel, it’s easy to get a race: the sender is still trying to send a value, but the channel is already closed—and the program crashes with a panic. In Go, this isn’t a “minor error,” it’s an explicit stop signal: “you broke the protocol.”
To really feel the danger, look at this anti-example (the code is intentionally “bad”; do not treat it as a pattern):
package main
func main() {
ch := make(chan int)
go func() {
ch <- 1 // may panic if the channel is closed earlier
}()
close(ch) // closed by the receiver (main), this is a bad idea
}
It’s like closing an elevator door while people are still trying to get in. Formally, the action is understandable, but after that it will be painful and loud.
3. Reading from a channel after it is closed
Four states when reading
Now the most useful part: let’s break reading behavior into clear cases. For the receiver, you can mentally view a channel along two axes: “closed / not closed” and “has values / has no values.” That yields four situations.
Below is a table—you should reread it a couple of times, because it’s foundational.
| Channel state | What v := <-ch does | What v, ok := <-ch does |
|---|---|---|
| Channel is not closed, there are no values (empty right now) | Blocks (waits for a send) | Blocks (also waits) |
| Channel is not closed, values exist | Returns the next value | Returns the value and ok=true |
| Channel is closed, values exist (e.g., in the buffer) | Returns the remaining values | Returns the value and ok=true |
| Channel is closed, there are no values | Returns the zero value immediately | Returns the zero value and ok=false |
The key idea: closing a channel makes reading safe and “completable.” The receiver no longer risks hanging forever—it can observe ok=false and exit.
Two-value receive: v, ok := <-ch
The most important skill of the day is reading from a channel in the “two-value form.”
The form is:
v, ok := <-ch
Meaning:
- ok == true means: you received a real value, everything is fine.
- ok == false means: the channel is closed and empty; there will be no more data.
Why is the “zero value” returned? Because Go must return a value of type T (since v has the channel element type). For int it’s 0, for string it’s "", for bool it’s false, for *Task it’s nil, and so on. So without ok, you can’t tell “a real zero” from “end of stream.”
A mini table of zero values (just to remove surprise):
| Channel element type | Zero value |
|---|---|
|
|
|
|
|
|
|
|
|
“a struct made up of zero values for its fields” |
Here is an example of a “bad protocol” that uses 0 as an end marker. It’s bad precisely because 0 can be a legal value:
package main
import "fmt"
func main() {
ch := make(chan int, 2)
ch <- 0 // legal value
ch <- 5 // one more value
// “End marker = 0” breaks right here:
v := <-ch
if v == 0 {
fmt.Println("end") // end (even though there is still data!)
return
}
fmt.Println(v)
}
The correct approach is not to invent “magic numbers,” but to close the channel and use ok on receive.
A receive loop “until closed” without range
There is a very convenient form for range ch, but we’ll set it aside: right now it’s important to learn to understand the mechanics via ok, manually, without “autopilot.” It’s like learning to drive with the pedals first, and only later enjoying cruise control.
Here is a “manual” receive loop:
package main
import "fmt"
func main() {
ch := make(chan int, 2)
ch <- 10
ch <- 20
close(ch)
for {
v, ok := <-ch
if !ok {
break
}
fmt.Println(v) // 10 then 20
}
}
Three things matter here at once.
- First: close does not destroy values that were already sent—they are still received.
- Second: when values run out, we get ok=false and exit.
- Third: the loop does not depend on whether the channel is buffered or not—the protocol is the same.
4. Practice and advanced scenarios
A task stream in TaskCLI
To avoid examples being “in a vacuum,” let’s continue the idea of a training application. Suppose we have a console utility TaskCLI that works with a task list. Previously we stored tasks in slices, filtered, sorted, and printed them. Now we’ll add a concurrent piece: one goroutine that “produces tasks” into a channel, and main that “reads tasks” and prints them.
Let’s start with a model (assume the Task struct is already familiar from earlier topics about struct):
package main
type Task struct {
ID int
Title string
Done bool
}
Now let’s build the producer: it iterates over a slice and sends tasks, then closes the channel. Notice: the close is inside the producer, i.e., “owned by the sender.”
package main
func produceTasks(ch chan Task, tasks []Task) {
for _, t := range tasks {
ch <- t
}
close(ch) // important: the sender closes it
}
Now in main we create a channel, start the producer goroutine, and read until closed via v, ok.
package main
import "fmt"
func main() {
tasks := []Task{
{ID: 1, Title: "learn Go", Done: false},
{ID: 2, Title: "drink tea", Done: true},
}
ch := make(chan Task)
go produceTasks(ch, tasks)
for {
t, ok := <-ch
if !ok {
break
}
fmt.Println(t.ID, t.Title, t.Done)
// 1 learn Go false
// 2 drink tea true
}
}
This code already demonstrates a “correct ending”: the program won’t hang even if there are no more tasks, because the channel gets closed.
Multiple senders and one close
With a single sender everything is simple: it sends, it closes. But what if there are multiple senders? For example, you parallelized reading tasks from different sources (still without select, just conceptually), and each one sends into one shared channel.
In that case, you cannot have each sender do close(ch)—someone will close early, and the others will attempt to send and crash the program.
This is where a classic trick appears: the channel is closed by a separate “coordinator” that waits for all senders to finish via WaitGroup, and closes the channel exactly once. This continues what we learned about WaitGroup earlier.
Here is the minimal pattern (still without complex constructs):
package main
import "sync"
func main() {
ch := make(chan int)
var wg sync.WaitGroup
wg.Add(2)
go func() { defer wg.Done(); ch <- 1 }()
go func() { defer wg.Done(); ch <- 2 }()
go func() {
wg.Wait()
close(ch) // close once, after all sends
}()
_ = ch
}
The moral here is: “the closer is the one who definitely knows sending is finished.” Sometimes that’s the actual sender; sometimes it’s a coordinator that owns the protocol.
panic: what is forbidden to do with close
With close there are two prohibitions, and both are very “physical,” like the laws of gravity: break them and you fall.
The first prohibition: you must not send into a closed channel.
The second prohibition: you must not close a channel twice.
Let’s show both as small examples (the lines that would cause panic are left commented out).
package main
func main() {
ch := make(chan int, 1)
ch <- 1
close(ch)
// ch <- 2 // panic: send on closed channel
// close(ch) // panic: close of closed channel
_ = ch
}
Why does Go panic instead of “silently ignoring” it? Because this is almost always a logical error in your protocol. If it were ignored, bugs would be much more “quiet” and nasty.
By the way, Go once had experimental “boolean forms” of non-blocking channel operations, but they were removed to avoid ambiguity and to keep the language simpler. And that’s good: in concurrency, clarity of rules matters more than a million “convenient” variants that nobody can debug later.
5. Common mistakes when closing channels and reading v, ok
Error #1: the receiver closes the channel “to stop the sender.”
This looks logical until a race appears: the sender is still working and does ch <- v, but the channel is already closed, and you get panic: send on closed channel. The receiver must not “command closure” via close. If you need to control stopping, that’s a separate protocol, and close remains a signal of “no more sends.”
Error #2: the channel is closed in two places.
This usually happens during refactoring: you moved sending into a function, left close(ch) inside, and then “just in case” added another close(ch) in main. The result is predictable: panic: close of closed channel. The fix is ownership discipline: a channel must have exactly one party responsible for closing it.
Error #3: using a “magic value” instead of close (for example, 0 or an empty string).
As long as your data stream “accidentally” never contains 0, everything seems to work. But as soon as 0 becomes a legal value, the receiver will start exiting too early—or, conversely, it won’t be able to tell where the end is. In Go, end-of-stream is indicated by close, and the fact of the end is detected via ok.
Error #4: reading v := <-ch and trying to guess whether it’s the end or data.
If you read without ok, then after the channel is closed you will receive the zero value and might treat it as a real value. This is especially nasty for types like int or string, where 0 and "" are often legal. The correct form for reading “until the end” is exactly v, ok := <-ch and checking if !ok { ... }.
Error #5: you closed the channel but expect it to “stop everything.”
Closing a channel is only a change in the behavior of read/write operations, not a “kill switch for goroutines.” If a goroutine is doing something else (for example, spinning in a loop, writing to another channel, or waiting for something), close will not terminate it. So it’s useful to keep this mental model: close is about communication (“no more values”), not about managing goroutine lifecycles.
GO TO FULL VERSION