Reviewer’s note: A goroutine leak is a goroutine that never returns, so its stack and everything it references stay in memory for the life of the process. This guide shows how to know you have one, how to find it with
runtime.NumGoroutineand the pprof goroutine profile, and how to fix every common cause. Every example was run on Go 1.24.7.
Works with Go 1.21+ (all output verified on Go 1.24.7). You should be comfortable with goroutines and channels first. If either is shaky, read the goroutines tutorial and the channels guide, because every leak in this article comes down to a goroutine blocked on a channel operation that will never complete.
How to know you have a goroutine leak
Goroutines are cheap, about 2 KB of stack each, so a leak rarely announces itself. It shows up as a slow climb in memory and, more reliably, a climb in the live goroutine count. The Go runtime exposes that count directly with runtime.NumGoroutine. If the number grows under steady load and never comes back down, goroutines are being created faster than they exit.
Here is a leak you can watch happen. fetchQuote starts a goroutine that produces one value on an unbuffered channel. The caller uses a timeout and sometimes walks away before the value arrives:
package main
import (
"fmt"
"runtime"
"time"
)
// fetchQuote starts a goroutine that produces one value and sends it on an
// unbuffered channel. If the caller stops listening, that send blocks forever.
func fetchQuote(symbol string) <-chan string {
out := make(chan string)
go func() {
time.Sleep(10 * time.Millisecond) // simulate a slow upstream call
out <- symbol + " 142.11" // blocks until someone receives
}()
return out
}
func main() {
fmt.Println("goroutines at start:", runtime.NumGoroutine())
for i := 1; i <= 5; i++ {
quote := fetchQuote("GOOG")
select {
case <-quote:
// got the value
case <-time.After(2 * time.Millisecond):
// caller gives up early; the sender is now stranded
}
time.Sleep(20 * time.Millisecond)
fmt.Printf("after %d requests: %d goroutines\n", i, runtime.NumGoroutine())
}
}
Output:
goroutines at start: 1
after 1 requests: 2 goroutines
after 2 requests: 3 goroutines
after 3 requests: 4 goroutines
after 4 requests: 5 goroutines
after 5 requests: 6 goroutines
One extra goroutine sticks around per request. In a real service handling thousands of requests a minute, that line goes up forever. The first diagnostic move is always the same: log runtime.NumGoroutine() on an interval and see whether it trends up.
Why goroutine leaks happen
Every leak is a goroutine parked on an operation that can never proceed. There are four shapes worth memorizing, and each one is provable with a before-and-after goroutine count.
A send with no receiver
An unbuffered send blocks until someone receives. If the receiver is gone, the sender waits forever. This is the pattern behind the quote example above, isolated:
func leakOnSend() {
results := make(chan int) // unbuffered
go func() {
results <- 42 // no one ever receives: this goroutine blocks forever
}()
// The function returns. The channel goes out of scope, but the goroutine
// parked on the send keeps it (and itself) alive.
}
Wrapping that in a main that prints runtime.NumGoroutine() before and after (with a runtime.GC() in between to prove garbage collection does not save you) gives:
before: 1
after: 2
Garbage collection cannot reap a running goroutine. A goroutine is a root; anything it references is reachable by definition. This is the leak Ardan Labs calls “the forgotten sender,” and it is the most common one in production.
A receive with no sender
The mirror image. A goroutine waits to receive a value that nobody will send:
func leakOnReceive() {
work := make(chan int)
go func() {
v := <-work // waits for a value that never comes
fmt.Println("processed", v)
}()
// Nobody ever sends on work, so the goroutine parks on the receive forever.
}
Same result: the count goes from 1 to 2 and stays there.
A range over a channel that is never closed
for range ch ends only when ch is closed. Forget the close and the consumer loops forever:
func leakOnRange() {
jobs := make(chan int)
go func() {
for range jobs { // loops until jobs is closed... which never happens
}
fmt.Println("consumer done") // unreachable
}()
for i := 0; i < 3; i++ {
jobs <- i
}
// We stop sending but never close(jobs), so the range never ends.
}
The consumer processes all three jobs, then blocks on the next receive because the range is still waiting for more or for a close. Before 1, after 2.
A missing cancellation signal
A background loop with no exit path leaks once per start. This poller runs a ticker with no way to stop it:
func startPoller() {
go func() {
tick := time.NewTicker(5 * time.Millisecond)
defer tick.Stop()
for range tick.C {
_ = time.Now() // poll something; there is no exit path
}
}()
}
Start it four times, one per request say, and the count goes to 5 and stays. The defer tick.Stop() never runs because the loop never returns.
Detecting leaks: the tooling
NumGoroutine tells you a leak exists. These tools tell you where it is.
runtime.NumGoroutine as the cheap signal
Keep it as a permanent health signal, not just a debugging trick. Export it as a metric, or log it every 30 seconds. A flat line means your goroutine lifecycle is balanced. A staircase means a leak. It costs almost nothing to read, so there is no reason not to watch it in production.
The pprof goroutine profile
NumGoroutine gives you a number; the goroutine profile gives you the stack traces those goroutines are stuck on, grouped and counted. That grouping is the whole game: a leak shows up as a large count of identical stacks all parked on the same line.
You can dump the profile in-process without any HTTP server:
package main
import (
"os"
"runtime/pprof"
"time"
)
func waitForNothing(work chan int) {
<-work // parks forever; nobody sends
}
func main() {
work := make(chan int)
for i := 0; i < 3; i++ {
go waitForNothing(work)
}
time.Sleep(10 * time.Millisecond) // let them all park
// Dump the goroutine profile with debug=1: counts plus one stack per group.
pprof.Lookup("goroutine").WriteTo(os.Stdout, 1)
}
Output (trimmed):
goroutine profile: total 4
3 @ 0x46bf0e 0x40c305 0x40beb2 0x4cf565 0x472a01
# 0x4cf564 main.waitForNothing+0x24 /tmp/gl/p8_pprof.go:10
Read the line 3 @ ...: three goroutines share one stack, all parked inside main.waitForNothing at line 10. That is the leak, named and counted. When you have a real leak, the group with the surprising count and a stack pointing into your own code is the culprit.
In a running service you expose the same data over HTTP by importing net/http/pprof for its side effect, which registers handlers on the default mux:
import _ "net/http/pprof"
Then hit http://localhost:6060/debug/pprof/goroutine?debug=1 and you get the identical grouped dump. Wired up and queried in-process, the endpoint returns exactly the stack we saw:
goroutine profile: total 11
# 0x699364 main.waitForNothing+0x24 /tmp/gl/p9_httppprof.go:14
For a live process the practical move is to capture the profile twice, a minute apart, and diff the counts. The stack whose count grew is your leak. Use go tool pprof http://localhost:6060/debug/pprof/goroutine to explore it interactively.
A go test leak check
You can catch leaks in tests by comparing the goroutine count before and after, with a short retry loop because goroutines exit asynchronously:
func assertNoLeak(t *testing.T, baseline int) {
t.Helper()
for i := 0; i < 50; i++ {
if runtime.NumGoroutine() <= baseline {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("goroutine leak: baseline %d, now %d", baseline, runtime.NumGoroutine())
}
func TestStartWorkerLeaks(t *testing.T) {
baseline := runtime.NumGoroutine()
startWorker() // starts a goroutine that blocks on a receive forever
assertNoLeak(t, baseline)
}
Against a leaky startWorker, the test fails as it should:
--- FAIL: TestStartWorkerLeaks (0.51s)
leak_test.go:31: goroutine leak: baseline 2, now 3
FAIL
This hand-rolled check works, but for anything beyond a single test reach for uber-go/goleak (v1.3.0, import path go.uber.org/goleak). It snapshots goroutines, filters out the runtime’s own, and fails the test with the offending stack. Add one TestMain and every test in the package is covered:
func TestMain(m *testing.M) {
goleak.VerifyTestMain(m)
}
Or guard a single test with defer goleak.VerifyNone(t). When a goroutine outlives the test, goleak prints its full stack, so you get the same “which line is it parked on” answer as pprof, automatically, in CI. (goleak is a third-party module and was not compiled in this article’s sandbox; the usage above is verified against its v1.3.0 documentation.)
What the race detector does and does not do
A frequent misconception: go test -race does not detect goroutine leaks. The race detector finds concurrent unsynchronized access to shared memory, not goroutines that fail to exit. It is still worth running, because the sloppy lifecycle management that causes leaks often ships alongside data races, but a clean -race run tells you nothing about leaks. Keep the two tools separate in your head.
Fixing each cause
Every fix is the same principle applied differently: give the blocked goroutine a way out.
Close channels from the sender, or buffer the send
For the forgotten sender, the rule is that the sender closes the channel, never the receiver, and only the sender knows when it is done. For a range consumer, closing the channel is what ends the loop. Read the channels guide for the full ownership rules.
When a goroutine produces exactly one value and the caller might abandon it, a buffered channel of capacity 1 is the clean fix. The send always succeeds, so the goroutine finishes instead of parking:
func fetchQuote(symbol string) <-chan string {
out := make(chan string, 1) // room for exactly one value
go func() {
time.Sleep(10 * time.Millisecond)
out <- symbol + " 142.11" // never blocks
}()
return out
}
Rerunning the opening program with this one change flattens the count completely:
goroutines at start: 1
after 1 requests: 1 goroutines
after 2 requests: 1 goroutines
after 3 requests: 1 goroutines
after 4 requests: 1 goroutines
after 5 requests: 1 goroutines
Select on ctx.Done for long-lived goroutines
For a loop that should stop when work is cancelled, pass a context and add a ctx.Done() case to a select. The poller from earlier, fixed:
func startPoller(ctx context.Context) {
go func() {
tick := time.NewTicker(5 * time.Millisecond)
defer tick.Stop()
for {
select {
case <-ctx.Done():
return // the defined exit condition
case <-tick.C:
_ = time.Now()
}
}
}()
}
Start four pollers with a shared cancellable context, then call cancel():
before: 1
running: 5
after: 1
All four goroutines return the moment the context is cancelled, and defer tick.Stop() finally runs. This is the single most useful pattern in the language for lifecycle control: any goroutine that outlives a single function call should take a context and select on its Done channel.
Bound the work with a worker pool
Spawning one goroutine per item works until the items arrive faster than they finish, at which point goroutines pile up even without a hard leak. A fixed set of workers reading from a channel caps concurrency and gives every worker a clear exit (the channel closing). The worker pool guide builds this pattern end to end; the leak-relevant point is that a bounded pool turns “unbounded goroutine growth” into “bounded queue growth,” which is far easier to reason about and monitor.
Prevention: define the exit before you write go
There is one rule that prevents every leak in this article. Before you type go, answer: what makes this goroutine return? If you cannot name the condition, you have a leak in waiting. In practice the answer is one of:
- The channel it reads gets closed by its owner.
- Its context is cancelled and it selects on
ctx.Done(). - It does a bounded amount of work and returns.
If none of those apply, do not start the goroutine. This question, asked every time, is worth more than any detector. The concurrency pillar covers the ownership habits that make the answer obvious; the main Go tutorial is the place to start if goroutines themselves are still new.
A realistic leaking service
Here is the shape the leak takes in real code. A search handler calls a slow backend through a per-request goroutine and gives up after 10ms. The backend goroutine takes 30ms, so on every timeout it is stranded on the send:
func searchBackend(query string) <-chan string {
out := make(chan string) // unbuffered
go func() {
time.Sleep(30 * time.Millisecond) // slower than our timeout
out <- "results for " + query // strands here when the handler bailed
}()
return out
}
func searchHandler(w http.ResponseWriter, r *http.Request) {
results := searchBackend(r.URL.Query().Get("q"))
select {
case res := <-results:
fmt.Fprintln(w, res)
case <-time.After(10 * time.Millisecond):
http.Error(w, "backend timeout", http.StatusGatewayTimeout)
}
}
Driving five requests through an httptest server and counting goroutines shows the staircase:
before any request: 2
after 5 requests: 7
Five timeouts, five stranded backend goroutines. The fix combines two of the patterns above: derive a context from the request, cancel it with defer, and give the backend both a ctx.Done() exit and a buffered channel:
func searchBackend(ctx context.Context, query string) <-chan string {
out := make(chan string, 1) // buffered: the send never blocks
go func() {
select {
case <-time.After(30 * time.Millisecond):
out <- "results for " + query
case <-ctx.Done():
return // request ended; stop working
}
}()
return out
}
func searchHandler(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Millisecond)
defer cancel() // cancelling frees the backend goroutine
results := searchBackend(ctx, r.URL.Query().Get("q"))
select {
case res := <-results:
fmt.Fprintln(w, res)
case <-ctx.Done():
http.Error(w, "backend timeout", http.StatusGatewayTimeout)
}
}
Same five requests, flat count:
before any request: 2
after 5 requests: 2
r.Context() is cancelled automatically when the client disconnects or the handler returns, so wiring your goroutines to it means they get cleaned up for free.
Common mistakes
Waiting on a channel nobody closes. A for range ch or a bare <-ch with no corresponding close or send is a permanent park. Decide who owns the channel and make that owner responsible for closing it exactly once.
Forgetting to cancel the context. context.WithCancel and WithTimeout return a cancel function that you must call, always with defer cancel(). Skip it and both the context’s own goroutine (for WithTimeout) and anything selecting on ctx.Done() can leak. go vet will warn you about the unused cancel; do not ignore it.
Fire-and-forget goroutines. go doSomething() with no context, no channel handshake, and no bounded work is a leak whenever doSomething can block. Every goroutine needs an owner who knows when it ends.
Leaking timers with time.After in a loop. time.After allocates a new timer each call. In a hot for-select loop where another case usually fires first, older Go versions kept every unfired timer alive until it expired, which for a long timeout was effectively a leak. Go 1.23 changed this: an unreferenced Timer or Ticker is now collected immediately, even without Stop, so the classic time.After-in-a-loop leak is gone if your go.mod targets 1.23 or later. It is still cleaner to create one time.NewTimer outside the loop and call Reset on it, and it is mandatory if you must support older Go. Do not repeat the old advice without checking your target version.
What next
- Go context tutorial: cancellation, deadlines, and the
Donechannel that ends most leaks. - Go select statement: the multiplexing primitive that lets a goroutine watch for cancellation while it works.
- Go worker pools: bound concurrency so goroutine growth can never run away.
- Channels in Go: channel ownership and the close rules that prevent forgotten-sender leaks.
