Question
In Go, is it acceptable to leave a channel open indefinitely and never call close on it if the program never checks whether the channel is closed? Could this cause a memory leak?
For example, is the following request-response pattern valid?
func GetResponse(requestCh chan<- Request, data RequestData) Response {
reply := make(chan Response)
requestCh <- Request{data: data, replyCh: reply}
return <-reply
}
The idea is that a new reply channel is created for each request, a request is sent to another goroutine, and the function waits for exactly one response.
Short Answer
By the end of this page, you will understand that Go channels do not need to be closed in every case. Closing a channel is only necessary when receivers need to be told that no more values will arrive. A channel that becomes unreachable can still be garbage-collected, even if it was never closed. You will also learn why the request-reply pattern shown here is common in Go, when it is safe, and what kinds of goroutine leaks or deadlocks you should actually watch out for.
Concept
In Go, a channel is a communication mechanism between goroutines. A common beginner misunderstanding is to think that every channel must eventually be closed, similar to closing a file or database connection. That is not how channels work.
A Go channel should be closed only when:
- no more values will ever be sent on it, and
- receivers need a signal that the stream is finished
If a channel is used for a limited exchange such as send one value, receive one value, there is often no need to close it at all.
In your example, a fresh reply channel is created for each request:
reply := make(chan Response)
That reply channel is used for exactly one purpose:
- one goroutine sends one
Response - one goroutine receives that
Response
After that, if no references to the channel remain, the channel object can be garbage-collected. Leaving it unclosed does not by itself cause a memory leak.
What matters more is whether goroutines become stuck forever. In Go, the more common issue is not "an open channel leaking memory" but:
- a sender blocked forever because nobody receives
- a receiver blocked forever because nobody sends
- a goroutine waiting forever due to missing coordination
So the real question is usually not "Was the channel closed?" but "Can any goroutine get stuck forever?"
In short:
Mental Model
Think of a channel like a conveyor belt between workers.
close(channel)means: no more packages will ever be placed on this belt- it does not mean: clean up the belt immediately
- it does not need to happen after every package
Your reply channel is like creating a tiny private belt for one conversation:
- You create the belt.
- You tell another worker where that belt is.
- The other worker places one package on it.
- You take the package.
- Nobody uses that belt again, so it can be thrown away later.
You do not need to put a big "closed" sign on that belt if both sides are already done with it.
The real problem would be if one worker waits at the belt forever because the other worker never shows up.
Syntax and Examples
In Go, you create and use channels like this:
a := make(chan int)
Send a value:
a <- 10
Receive a value:
value := <-a
Close a channel when you want to signal that no more values will be sent:
close(a)
Example: one value, no close needed
package main
import "fmt"
func main() {
ch := make(chan string)
go func() {
ch <- "done"
}()
msg := <-ch
fmt.Println(msg)
}
This is perfectly fine.
- one goroutine sends one value
- main receives one value
- the channel is never closed
- no leak happens just because
closewas omitted
Step by Step Execution
Consider this small example:
package main
import "fmt"
type Request struct {
n int
replyCh chan int
}
func GetDouble(requestCh chan<- Request, n int) int {
reply := make(chan int)
requestCh <- Request{n: n, replyCh: reply}
return <-reply
}
func worker(requestCh <-chan Request) {
for req := range requestCh {
req.replyCh <- req.n * 2
}
}
func main() {
requestCh := make(chan Request)
go worker(requestCh)
result := GetDouble(requestCh, 21)
fmt.Println(result)
}
Step by step:
maincreatesrequestCh.mainstarts in a new goroutine.
Real World Use Cases
Channels that are never explicitly closed are common in real Go programs when they are used for a single handoff or tightly controlled synchronization.
Common situations
-
Request-response between goroutines
- one goroutine sends work
- another sends back exactly one result
-
Worker coordination
- a goroutine waits for a single success or failure signal
-
Timeout and cancellation patterns
- some internal channels exist briefly and are not manually closed
-
Futures/promises style results
- create a channel for one eventual answer
Example: job execution result
resultCh := make(chan error)
go func() {
resultCh <- runJob()
}()
err := <-resultCh
No close is necessary here because only one value is sent.
Example: API handler delegating work
A server goroutine might send a request to a worker manager and wait on a private reply channel for a single processed result. This keeps communication simple and avoids shared mutable state.
Real Codebase Usage
In real codebases, developers usually think about ownership and termination semantics.
Common pattern: sender closes, receiver does not
A channel should generally be closed by the goroutine that sends on it, because that goroutine knows when no more values will be sent.
Per-request reply channels are often not closed
Why?
- they carry one result
- the receiver already knows when it is done after one receive
- a close signal adds no useful information
Useful patterns around this concept
Guarding against blocked responses
If the worker may fail to respond, use select with a timeout or context cancellation.
select {
case resp := <-reply:
return resp
case <-ctx.Done():
return Response{}
}
Validation before sending work
if requestCh == nil {
return Response{}
}
A nil channel blocks forever on send and receive, so codebases often guard against that.
Error handling in reply messages
Real projects often include both result and error in the response:
Response {
Value
Err
}
Common Mistakes
Mistake 1: thinking every channel must be closed
Broken assumption:
ch := make(chan int)
ch <- 1
close(ch) // not always needed
Closing is not a cleanup requirement. It is a communication signal.
How to avoid it:
- close only when receivers need to know no more values are coming
- do not add
closeautomatically everywhere
Mistake 2: closing from the receiving side
Broken code:
func read(ch <-chan int) {
close(ch) // compile error: cannot close receive-only channel
}
Even with a bidirectional channel, the receiver usually should not close it.
How to avoid it:
- let the sender close the channel
- define channel ownership clearly
Mistake 3: closing a channel too early
Broken code:
ch := make(chan int)
(ch)
ch <-
Comparisons
| Concept | What it means | Do you need close? | Typical use |
|---|---|---|---|
| One send, one receive | A single handoff between goroutines | Usually no | Reply channels, result channels |
| Finite stream of values | Sender produces several values, then stops | Usually yes | Pipelines, worker output |
| Infinite or long-lived service channel | Channel stays active for life of service | Not necessarily | Request queues, background workers |
close(ch) | Signal that no more values will be sent | Only when receivers need that signal | Ending range loops |
| Garbage collection | Memory cleanup when object is unreachable | Independent of |
Cheat Sheet
ch := make(chan T) // unbuffered channel
ch := make(chan T, 1) // buffered channel
ch <- value // send
value := <-ch // receive
close(ch) // signal no more sends
Rules to remember
- Channels do not need to be closed just for cleanup.
- Close a channel only to signal: no more values are coming.
- The sender usually closes the channel.
- Sending on a closed channel causes a panic.
- Receiving from a closed channel is allowed.
- A channel that becomes unreachable can be garbage-collected even if not closed.
- Goroutine leaks are usually a bigger problem than unclosed channels.
Good fit for no close
reply := make(chan Response)
requestCh <- Request{replyCh: reply}
resp := <-reply
Good fit for close
go func() {
for _, n := range []int{1, 2, 3} {
ch <- n
}
(ch)
}()
n := ch {
fmt.Println(n)
}
FAQ
Do Go channels always need to be closed?
No. Only close a channel when receivers need to know that no more values will be sent.
Does leaving a channel open cause a memory leak in Go?
Not by itself. If nothing references the channel anymore, it can be garbage-collected even if it was never closed.
Is a per-request reply channel safe in Go?
Yes, if one goroutine sends exactly one reply and the caller receives exactly one reply.
What is more dangerous than not closing a channel?
A goroutine blocked forever on send or receive. That is often the real source of leaks and hangs.
Who should close a channel in Go?
Usually the sending goroutine, because it knows when no more values will be sent.
Should I close a reply channel after sending one value?
Usually no. If the receiver only does one receive, closing adds no benefit.
What happens if the worker never sends to the reply channel?
The caller blocks forever unless you add a timeout or context cancellation.
Can I use a buffered reply channel instead?
Yes. A buffer of 1 can sometimes reduce blocking risk, but you should use it intentionally and understand the behavior change.
Mini Project
Description
Build a small request-response worker system in Go. A client function sends jobs to a worker goroutine, and each job includes its own reply channel. This demonstrates that reply channels used for one response do not need to be explicitly closed, while also showing how to avoid hanging forever by using a timeout.
Goal
Create a worker that squares numbers and returns the result through a per-request reply channel, with timeout protection in the caller.
Requirements
- Create a
Jobstruct containing a number and a reply channel. - Start a worker goroutine that reads jobs and sends back one result per job.
- Write a function that submits a job and waits for the result.
- Add a timeout so the caller does not block forever.
- Print the result for at least two jobs.
Keep learning
Related questions
Blank Identifier Imports in Go: What `_` Means in an Import Statement
Learn what `_` means in a Go import, why blank identifier imports run package init code, and when to use them safely.
Calling Functions Across Files in the Same Go Package
Learn how Go uses packages across multiple files, why functions may appear undefined, and how to organize code correctly.
Check if a Value Exists in a Slice in Go
Learn how to check whether a value exists in a slice in Go, and why Go has no Python-style `in` operator for arrays or slices.