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:
- Unclosed channels are often fine
- Unused unreachable channels can be garbage-collected
- Blocked goroutines are the real danger
Your pattern is valid if the other side always sends exactly one reply.
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
Example: your request-reply pattern
package main
import "fmt"
type RequestData struct {
Name string
}
type Response struct {
Message string
}
type Request struct {
data RequestData
replyCh chan Response
}
func GetResponse(requestCh chan<- Request, data RequestData) Response {
reply := make(chan Response)
requestCh <- Request{data: data, replyCh: reply}
return <-reply
}
func worker(requestCh <-chan Request) {
for req := range requestCh {
req.replyCh <- Response{Message: "Hello, " + req.data.Name}
}
}
func main() {
requestCh := make(chan Request)
go worker(requestCh)
res := GetResponse(requestCh, RequestData{Name: "Go"})
fmt.Println(res.Message)
}
This is a common Go pattern.
The per-request replyCh does not need to be closed because:
- only one response is expected
- the caller performs one receive
- the channel is not reused as a stream
When close is useful
If a receiver loops over a channel, closing becomes important:
for value := range ch {
fmt.Println(value)
}
This loop stops only when ch is closed. In that kind of design, close(ch) matters.
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.mainstartsworkerin a new goroutine.maincallsGetDouble(requestCh, 21).GetDoublecreates a newreplychannel.GetDoublesendsRequest{n: 21, replyCh: reply}onrequestCh.workerreceives that request fromrequestCh.workercomputes21 * 2.workersends42onreplyCh.GetDoublereceives42fromreply.GetDoublereturns42.- The
replychannel is no longer referenced, so it can later be garbage-collected.
Important observation:
- the reply channel was never closed
- nothing went wrong
- the program still works correctly
The channel existed only to coordinate one exchange.
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:
type Response struct {
Value string
Err error
}
Buffered reply channels for safety
Sometimes developers use a buffer of 1:
reply := make(chan Response, 1)
This can help prevent a worker from blocking if the caller abandons the wait path, though it changes behavior and should be used intentionally.
What teams care about most
In production, teams usually ask:
- Can this goroutine become stuck?
- Can this request hang forever?
- Is cancellation supported?
- Who is responsible for sending and who owns closing?
Those questions matter more than whether every channel is explicitly closed.
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)
close(ch)
ch <- 1 // panic: send on closed channel
How to avoid it:
- only close after all sends are complete
- if multiple goroutines send, coordinate carefully
Mistake 4: leaking goroutines, not channels
Broken code:
func GetResponse(requestCh chan<- Request, data RequestData) Response {
reply := make(chan Response)
requestCh <- Request{data: data, replyCh: reply}
return <-reply // blocks forever if nobody replies
}
This is only safe if some goroutine always reads from requestCh and always writes to reply.
How to avoid it:
- ensure the worker always sends a response
- use
context.Contextor timeouts for cancellation - consider buffered channels when appropriate
Mistake 5: ranging over a channel that is never closed
Broken code:
for v := range ch {
fmt.Println(v)
}
If ch is never closed, this loop may never end.
How to avoid it:
- close channels that represent finite streams
- or use explicit receive logic with another exit condition
Mistake 6: using nil channels accidentally
Broken code:
var ch chan int
ch <- 1 // blocks forever
How to avoid it:
- initialize channels with
make - validate struct fields and dependencies before use
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 close | All heap objects, including channels |
close vs garbage collection
| Topic | close | Garbage collection |
|---|---|---|
| Purpose | Signal channel completion | Reclaim unreachable memory |
| Triggered by | Your code | Go runtime |
| Required for memory cleanup? | No | Yes |
| Affects receives? | Yes | No |
Unclosed channel vs goroutine leak
| Situation | Problem? | Why |
|---|---|---|
| Channel not closed, no references remain | Usually no | It can be garbage-collected |
Goroutine waiting forever on <-ch | Yes | The goroutine is stuck |
Goroutine waiting forever on ch <- v | Yes | The goroutine is stuck |
Channel used in for range but never closed | Often yes | Receiver may wait forever |
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
}
close(ch)
}()
for n := range ch {
fmt.Println(n)
}
Edge cases
nilchannel: send/receive blocks forever- closed channel: send panics
- closed channel: receive returns zero value after buffered values are drained
- multiple senders: closing safely requires coordination
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
Are Go Maps Passed by Value or Reference?
Learn Go map parameter and return semantics: maps are passed by value, while copies share underlying data. Know when map pointers are useful.
Authentication for Go REST APIs: Sessions, Tokens, and Middleware
Learn practical authentication patterns for Go REST APIs, including password hashing, server sessions, bearer tokens, middleware, and security rules.
Automatic Build Versioning in Go: Embed Incrementing Build Numbers
Learn how to add automatic build versioning in Go using linker flags, build metadata, CI counters, and Git-based version values.