How Do I Detect and Fix Goroutine Leaks in Go?
How do I detect and fix goroutine leaks in Go?
TL;DR
- Bottom line: A goroutine leak occurs when a goroutine is started but never terminates,
usually because it is blocked on a channel send/receive, waiting on a lock, or stuck in a loop with no
exit path. Detect with
runtime.NumGoroutine()trending,net/http/pprofgoroutine dumps, or Uber'sgoleakin tests. Fix by ensuring every goroutine has a guaranteed exit path viacontext.Contextcancellation, buffered channels, orerrgroup. - Key tool/command:
go.uber.org/goleakwithdefer goleak.VerifyNone(t)in tests;http://localhost:6060/debug/pprof/goroutine?debug=2in production. - Watch out for: Unbuffered channels where the sender blocks forever when the receiver returns early (the "forgotten sender" pattern). This is the #1 cause of goroutine leaks.
- Works with: Go 1.13+.
goleakv1.3.0 (Oct 2024) supports the two most recent Go minor versions.synctestexperimental in Go 1.24, production-ready in Go 1.25+. Experimentalgoroutineleakpprof profile in Go 1.26 (released Feb 2026, opt-in viaGOEXPERIMENT=goroutineleakprofile), planned default-on in Go 1.27.
Constraints
- The Go runtime does NOT forcibly terminate goroutines. Once started, a goroutine runs until it returns or
the program exits. There is no
goroutine.Kill(). goleak.VerifyNone(t)is incompatible witht.Parallel(). Usegoleak.VerifyTestMain(m)for parallel test packages. [src2, src5]runtime.NumGoroutine()includes runtime-internal goroutines (GC, finalizer, signal handler). Raw count comparisons can produce false positives. [src6]- Never rely solely on
go vetor the race detector to find goroutine leaks — neither detects them. [src4] - The
synctestpackage (Go 1.24+) only detects goroutines blocked on synchronization primitives. CPU-bound infinite loops are not detected. [src3]
Quick Reference
| # | Cause | Likelihood | Signature | Fix |
|---|---|---|---|---|
| 1 | Unbuffered channel — forgotten sender | ~35% | Goroutine blocked on ch <- with no receiver |
Use buffered channel make(chan T, 1) or drain before return [src1]
|
| 2 | Unbuffered channel — forgotten receiver | ~20% | Goroutine blocked on <-ch with no sender/close |
Close channel when sender is done: defer close(ch) [src1]
|
| 3 | Missing context cancellation | ~20% | Goroutine in select{} without ctx.Done() case |
Always pass and check context.Context [src7] |
| 4 | Early return skipping channel reads | ~10% | Multiple goroutines spawned, only first result consumed | Use errgroup.Group or drain all channels [src4] |
| 5 | Range over unclosed channel | ~5% | for v := range ch blocks forever |
Sender must close(ch) when done [src3] |
| 6 | Infinite loop without exit condition | ~5% | Goroutine in for{} with no return |
Add ctx.Done() or done channel check [src7] |
| 7 | Leaked time.Ticker goroutine |
~3% | time.NewTicker without ticker.Stop() |
Always defer ticker.Stop() [src7] |
| 8 | Orphaned background worker | ~2% | Library starts goroutine; caller forgets Stop()/Close() |
Follow library contract — call cleanup methods [src3] |
Decision Tree
START — suspected goroutine leak
├── In tests?
│ ├── YES → Add defer goleak.VerifyNone(t) [src2]
│ │ ├── Using t.Parallel()? → Use goleak.VerifyTestMain(m) [src5]
│ │ └── Go 1.24+? → Consider synctest.Test() [src3]
│ └── NO ↓
├── In production/development?
│ ├── Can add pprof endpoint?
│ │ ├── YES → import _ "net/http/pprof"; /debug/pprof/goroutine?debug=2 [src6]
│ │ └── NO → Log runtime.NumGoroutine() periodically [src7]
│ └── Have Prometheus? → Export go_goroutines; alert on sustained increase [src7]
│
├── Leak confirmed — what is blocked?
│ ├── Channel send (ch <-) → Receiver missing or returned early
│ │ ├── Can buffer? → make(chan T, 1) [src1]
│ │ └── Multiple senders? → errgroup or drain all channels [src4]
│ ├── Channel receive (<-ch) → Sender never sends or closes
│ │ └── Ensure sender calls close(ch) [src1]
│ ├── select{} without ctx.Done() → Add cancellation case [src7]
│ ├── Mutex/lock → Check for deadlock (separate issue)
│ └── I/O operation → Add timeout via context or Deadline [src7]
│
└── DEFAULT → Use pprof goroutine dump to identify blocked stack [src6]
Step-by-Step Guide
1. Add goleak to your test suite
The fastest way to catch goroutine leaks is in tests. [src2, src5]
go get -u go.uber.org/goleak
package mypackage
import (
"testing"
"go.uber.org/goleak"
)
func TestNoLeak(t *testing.T) {
defer goleak.VerifyNone(t)
// Your test code here.
// goleak checks for unexpected goroutines when the test exits.
}
Verify: go test -v ./... — if a goroutine leaks, goleak prints the leaked
goroutine's stack trace and fails the test.
2. Use goleak.VerifyTestMain for package-wide detection
For parallel tests or package-wide coverage. [src2, src5]
package mypackage
import (
"testing"
"go.uber.org/goleak"
)
func TestMain(m *testing.M) {
goleak.VerifyTestMain(m)
}
Verify: go test -v ./... — all tests checked for leaks after the entire suite
finishes.
3. Enable pprof for runtime detection
For production or development environments. [src6, src7]
import (
"net/http"
_ "net/http/pprof" // registers /debug/pprof/* handlers
)
func main() {
// Bind pprof to a separate internal port in production
go func() {
http.ListenAndServe("localhost:6060", nil)
}()
// ... rest of application
}
Verify:
curl http://localhost:6060/debug/pprof/goroutine?debug=2 — shows full stack traces. Look for
goroutines blocked on channel operations or select statements.
4. Monitor runtime.NumGoroutine() over time
For quick sanity checks and alerting. [src6, src7]
import (
"log"
"runtime"
"time"
)
func monitorGoroutines(interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
baseline := runtime.NumGoroutine()
for range ticker.C {
current := runtime.NumGoroutine()
if current > baseline*2 {
log.Printf("WARNING: goroutine count %d exceeds 2x baseline %d",
current, baseline)
}
}
}
Verify: Run under load, then idle. Goroutine count should return to baseline.
5. Use synctest for built-in detection (Go 1.24+, production-ready in 1.25+)
No third-party dependencies needed. [src3]
package mypackage
import (
"testing"
"testing/synctest"
)
func TestNoLeakSynctest(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
// Test code with goroutines
synctest.Wait()
})
// Panics if blocked goroutines remain
}
Verify: GOEXPERIMENT=synctest go test -v ./... (Go 1.24 only). In Go 1.25+, no
experiment flag needed — synctest is production-ready.
6. Analyze pprof goroutine dump to find the leak
Identify the specific blocked stacks. [src6]
# Full stack traces
curl -s http://localhost:6060/debug/pprof/goroutine?debug=2
# Grouped summary
curl -s http://localhost:6060/debug/pprof/goroutine?debug=1
# Interactive analysis
go tool pprof http://localhost:6060/debug/pprof/goroutine
# Commands: top10, list funcName, web
Verify: Blocked goroutines show stacks ending in chan send,
chan receive, select, or sync.Mutex.Lock.
7. Enable the Go 1.26 goroutineleak pprof profile (long-running services)
For long-running services where goleak cannot help, Go 1.26 (released Feb 2026) ships an
experimental GC-based profile that flags goroutines blocked on concurrency primitives the GC has marked
unreachable. [src3, src8]
# Build with the experiment enabled
GOEXPERIMENT=goroutineleakprofile go build -o myapp ./cmd/myapp
# Expose net/http/pprof as usual, then curl the new endpoint
curl -s http://localhost:6060/debug/pprof/goroutineleak > leak.pb.gz
go tool pprof leak.pb.gz
# Commands: top10, list funcName, web
Verify: Only orphaned leaks (primitives unreachable from any runnable goroutine) are reported.
Logically-stuck goroutines parked on a globally-reachable channel will NOT appear — pattern-based review and
goleak are still required. Production-ready per the proposal; planned default-on in Go 1.27.
Code Examples
Go: Fix forgotten sender with buffered channel
// Input: Function that spawns a goroutine to do work with a timeout
// Output: Result or timeout error — no goroutine leak
func fetchWithTimeout(ctx context.Context) (Result, error) {
ch := make(chan Result, 1) // buffered — sender never blocks
go func() {
time.Sleep(2 * time.Second)
ch <- Result{Data: "success", Err: nil}
// Even if ctx cancelled, send completes into buffer
}()
select {
case res := <-ch:
return res, res.Err
case <-ctx.Done():
return Result{}, ctx.Err()
}
}
Go: Fix early return with errgroup
// Input: Multiple concurrent tasks where failure cancels the rest
// Output: First error encountered, all goroutines cleaned up
func fetchAll(ctx context.Context, urls []string) error {
g, ctx := errgroup.WithContext(ctx)
for _, url := range urls {
g.Go(func() error {
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil { return err }
resp, err := http.DefaultClient.Do(req)
if err != nil { return err } // cancels ctx for others
defer resp.Body.Close()
if resp.StatusCode != 200 {
return fmt.Errorf("%s returned %d", url, resp.StatusCode)
}
return nil
})
}
return g.Wait()
}
Go: Worker pool preventing unbounded goroutines
// Input: Stream of jobs
// Output: Processed results with bounded goroutine count
func workerPool(ctx context.Context, jobs <-chan int, n int) <-chan string {
results := make(chan string, n)
var wg sync.WaitGroup
for i := 0; i < n; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for {
select {
case job, ok := <-jobs:
if !ok { return } // channel closed
results <- fmt.Sprintf("worker %d: job %d", id, job)
case <-ctx.Done():
return // cancelled
}
}
}(i)
}
go func() { wg.Wait(); close(results) }()
return results
}
Anti-Patterns
Wrong: Unbuffered channel with early return
// BAD — goroutine writing to ch2 leaks when ch1 returns error [src4]
func fetchTwo() error {
ch1 := make(chan error)
ch2 := make(chan error)
go func() { ch1 <- doWork1() }()
go func() { ch2 <- doWork2() }()
if err := <-ch1; err != nil {
return err // ch2 sender blocks forever — LEAKED
}
return <-ch2
}
Correct: Use errgroup to coordinate goroutines
// GOOD — errgroup waits for all goroutines; no leak [src4]
func fetchTwo() error {
var g errgroup.Group
g.Go(func() error { return doWork1() })
g.Go(func() error { return doWork2() })
return g.Wait() // waits for both; returns first error
}
Wrong: Goroutine blocked after timeout
// BAD — goroutine sending to ch blocks forever after timeout [src1]
func fetchWithTimeout() (string, error) {
ch := make(chan string) // unbuffered!
go func() {
result := slowOperation()
ch <- result // blocks forever if main timed out
}()
select {
case r := <-ch:
return r, nil
case <-time.After(1 * time.Second):
return "", fmt.Errorf("timeout") // goroutine LEAKED
}
}
Correct: Buffer the channel
// GOOD — buffered channel lets goroutine complete [src1]
func fetchWithTimeout() (string, error) {
ch := make(chan string, 1) // buffered!
go func() {
result := slowOperation()
ch <- result // always succeeds into buffer
}()
select {
case r := <-ch:
return r, nil
case <-time.After(1 * time.Second):
return "", fmt.Errorf("timeout") // goroutine exits cleanly
}
}
Wrong: Range over channel with no close
// BAD — consumer blocks forever because nobody closes ch [src3]
func process(items []int) <-chan int {
ch := make(chan int)
go func() {
for _, item := range items {
ch <- item * 2
}
// BUG: forgot close(ch)
}()
return ch
}
// for result := range process(items) { ... } // blocks forever
Correct: Always close the channel when done
// GOOD — close(ch) signals consumer to exit range loop [src3]
func process(items []int) <-chan int {
ch := make(chan int)
go func() {
defer close(ch) // always close when done sending
for _, item := range items {
ch <- item * 2
}
}()
return ch
}
// for result := range process(items) { ... } // exits cleanly
Common Pitfalls
- Forgetting to cancel derived contexts: Creating
ctx, cancel := context.WithCancel(parentCtx)and not callingdefer cancel()leaks the goroutine watching the context timer. Always defer cancel immediately. [src7] - Using
time.Afterin a loop: Each call in aselectinside a loop creates a new timer goroutine that isn't collected until it fires. Usetime.NewTimer/timer.Reset()instead. [src7] - Ignoring goleak false positives: Some libraries (gRPC, database drivers) start
background goroutines. Use
goleak.IgnoreTopFunction("google.golang.org/grpc...")to whitelist known safe goroutines. [src2, src5] select{}without a done channel: An emptyselect{}blocks the goroutine forever. Always includecase <-ctx.Done(): return. [src7]- Not draining channels on cancellation: When a producer checks
ctx.Done()but the consumer returned, the producer's final send still blocks. Use buffered channels or a drain goroutine. [src1, src4] - Testing only the happy path: Goroutine leaks often occur in error and timeout paths.
Test these paths explicitly with
goleak.VerifyNone(t). [src2]
Diagnostic Commands
# === pprof goroutine dump (full stack traces) ===
curl -s http://localhost:6060/debug/pprof/goroutine?debug=2
# === pprof goroutine summary (grouped by stack) ===
curl -s http://localhost:6060/debug/pprof/goroutine?debug=1
# === Interactive pprof analysis ===
go tool pprof http://localhost:6060/debug/pprof/goroutine
# Commands: top10, list funcName, web, traces
# === Compare profiles (before/after load test) ===
go tool pprof -base goroutine_before.pb.gz goroutine_after.pb.gz
# === Run tests with goleak ===
go test -v -run TestMyFunc ./...
# === Check goroutine count in tests ===
go test -v -count=1 ./... 2>&1 | grep -i "goroutine\|leak"
# === Go 1.24 synctest (1.25+ no flag needed) ===
GOEXPERIMENT=synctest go test -v ./...
# === Go 1.26 goroutineleak profile (build-time opt-in) ===
GOEXPERIMENT=goroutineleakprofile go build -o myapp ./cmd/myapp
curl -s http://localhost:6060/debug/pprof/goroutineleak > leak.pb.gz
go tool pprof leak.pb.gz
Version History & Compatibility
| Feature / Tool | Available Since | Notes |
|---|---|---|
runtime.NumGoroutine() |
Go 1.0 | Built-in; includes runtime goroutines [src7] |
net/http/pprof goroutine profile |
Go 1.0 | debug=1 (summary), debug=2 (full stacks) [src6] |
context.WithCancel / WithTimeout |
Go 1.7 | Standard cancellation pattern [src7] |
golang.org/x/sync/errgroup |
Go 1.7 (module) | Coordinated goroutine groups [src4] |
go.uber.org/goleak v1.0 |
2018 | VerifyNone, VerifyTestMain [src2] |
goleak v1.1.0 (IgnoreCurrent) |
2021 | Filter pre-existing goroutines [src5] |
goleak v1.3.0 (IgnoreAnyFunction) |
2024 | Match function anywhere in stack [src5] |
testing/synctest |
Go 1.24 (2025-02, experimental); Go 1.25 (production-ready) | Built-in leak detection in tests; no flag needed in 1.25+ [src3] |
runtime/pprof goroutineleak profile |
Go 1.26 (2026-02, experimental) | GC-based leak detection, opt-in via GOEXPERIMENT=goroutineleakprofile; planned
default-on in Go 1.27 [src3, src8] |
When to Use / When Not to Use
| Use When | Don't Use When | Use Instead |
|---|---|---|
| Goroutine count grows during idle | Memory grows but goroutine count stable | Memory profiler (pprof heap) |
| Test occasionally hangs or times out | Test fails deterministically with stack trace | Standard debugging / go vet |
| pprof shows blocked goroutine stacks | All goroutines are active (CPU-bound) | CPU profiler (pprof profile) |
| Channel operations block indefinitely | Mutex deadlock (all goroutines blocked) | Deadlock detector (go vet, -race) |
| Need CI-integrated leak detection | Production monitoring only | Prometheus go_goroutines metric |
Important Caveats
goleakuses polling with exponential backoff: It checks up to 20 times with delays from 1us to 100ms. Very fast goroutines that start and stop between checks may not be caught. [src2]synctestis still evolving: The API changed between Go 1.24 and 1.25. Pin your Go version and check release notes before upgrading. [src3]- The
goroutineleakpprof profile (Go 1.26, released Feb 2026, experimental) uses GC marking: It cannot detect goroutines blocked on reachable synchronization objects (e.g., a global channel). It only catches goroutines blocked on unreachable objects. Enable at build time withGOEXPERIMENT=goroutineleakprofile; profile is exposed at/debug/pprof/goroutineleak. Planned default-on in Go 1.27. Adds zero runtime overhead unless actively in use. [src3, src8] runtime.NumGoroutine()baseline varies: HTTP servers, database connection pools, and gRPC clients all maintain background goroutines. Establish a per-application baseline before alerting. [src6]- Goroutine leaks compound: Each leaked goroutine holds its entire stack (2-8 KB initial, can grow to 1 GB). In high-throughput services, 50,000 leaked goroutines can consume hundreds of MB of RAM and thousands of file descriptors. [src6]