Goroutine Leaks
A goroutine leak is a goroutine that is permanently blocked on a concurrency primitive — a channel send/receive, a mutex, a [[sync.Cond Internals|
sync.Cond]], a [[sync.WaitGroup Internals|WaitGroup]] — and can never become unblocked, yet is never garbage-collected because a goroutine is itself a GC root. Each leaked goroutine permanently holds its stack (a minimum 8 KiB) and every object reachable from it; a leak is therefore an unbounded memory leak that no amount ofGOGCtuning fixes. Go 1.26 (released February 2026) ships an experimentalgoroutineleakprofile inruntime/pprofthat detects a large class of these leaks by reusing the garbage collector’s reachability analysis (Go 1.26 release notes).
Mental Model
A goroutine is never freed by the GC — the runtime only reclaims a goroutine’s resources when its top-level function returns. So a goroutine that blocks forever is, by construction, immortal. The leak is not the goroutine struct itself (small) but everything it pins: its stack and the transitive closure of objects its stack and registers reference.
The Go 1.26 detector’s central insight: a blocked goroutine can only ever wake if some still-running goroutine can reach the primitive it is blocked on. If goroutine G is parked on channel C, and C is unreachable from every runnable goroutine — and from every goroutine those could in turn unblock — then nothing can ever send on C, receive from it, or close it. G is provably dead. This is exactly a reachability question, and the GC already answers reachability questions for a living. The detector is a second mark phase: instead of “is this object reachable, so keep it,” it asks “is this primitive reachable from a runnable goroutine, so the parked goroutine waiting on it could still wake.”
graph LR subgraph "Runnable / could-run set" R["runnable goroutines"] R --> P1["channels & locks<br/>reachable from R"] end subgraph "Blocked goroutines" B1["G1 blocked on chan A"] B2["G2 blocked on chan B"] end P1 -.->|"A is reachable"| B1 B1 -->|"could wake → its primitives join the set"| P1 B2 -. "chan B NOT reachable<br/>from any runnable g" .-> LEAK["G2 is LEAKED"] caption["Transitive closure: start from runnable goroutines, mark every primitive they reach,<br/>then every goroutine that could wake on those primitives, repeat to a fixpoint.<br/>Any goroutine left blocked on an unreached primitive is reported as leaked."]
Diagram: leak detection is a fixpoint over reachability. The insight — a leak is precisely a parked goroutine whose primitive falls outside the closure of “things a runnable goroutine can ever touch.”
Mechanical Walk-through
Why goroutines leak in the first place
The canonical leak is a producer blocked on an unbuffered channel whose consumer went away. From the Go 1.26 release notes’ own example:
func processWorkItems(ws []workItem) ([]workResult, error) {
ch := make(chan result)
for _, w := range ws {
go func() {
res, err := processWorkItem(w)
ch <- result{res, err} // (1) blocks until someone receives
}()
}
var results []workResult
for range len(ws) {
r := <-ch
if r.err != nil {
return nil, r.err // (2) early return — stops receiving
}
results = append(results, r.res)
}
return results, nil
}When processWorkItem errors and the function returns early at (2), the remaining worker goroutines are still blocked at (1) trying to send on ch. The local variable ch goes out of scope, the receiver is gone, and nobody else holds a reference to the channel. Those goroutines will send “until the heat death of the universe” — they leak. This is the blocked-channel leak, the most common shape in real code.
Other shapes: a range over a channel that is never closed (the range blocks on the final receive forever); a sync.WaitGroup.Wait() where one Done() is missing; a goroutine selecting on a [[The Context Package|context]] Done() channel where the context is never cancelled; a worker reading from a request channel that the producer abandoned; a deadlocked pair (which is also a deadlock — leaks and deadlocks overlap but are not the same: a deadlock typically halts the program, a leak quietly accumulates while the program runs fine).
A subtler family is the leak under cancellation that ignores cancellation. A goroutine handed a context.Context is only protected if it actually selects on ctx.Done(). A goroutine that does result := <-slowChan with no select will block on slowChan regardless of how many times its context is cancelled — the cancellation signal exists but nothing reads it. The leak is not the absence of a context; it is the goroutine’s blocking operation not being cancellation-aware. Every potentially-blocking operation in a goroutine whose lifetime is meant to be bounded must be wrapped in a select that also watches ctx.Done(), or the context buys nothing.
The reason leaks are so costly is the transitive pinning. A leaked goroutine that captured a 1-byte closure variable does not leak 1 byte — it leaks its entire stack (8 KiB minimum, grown larger if it ever recursed deep), the closure, and every object the closure’s pointers reach. A goroutine that captured a large buffer, an *http.Request, or a database row set pins all of it permanently. Ten thousand leaked goroutines, each modest on its own, is tens of megabytes of stacks plus an unbounded heap closure — and because the memory is spread thin across many goroutines, it does not look like a classic single-site heap leak.
The 1.26 detector — GC-based reachability
The experimental profile is enabled at build time with GOEXPERIMENT=goroutineleakprofile; it is not a runtime flag. With the experiment built in, the profile is exposed two ways: the goroutineleak profile type in runtime/pprof, and the /debug/pprof/goroutineleak HTTP endpoint via net/http/pprof (Go 1.26 release notes).
When the profile is collected, the runtime runs a specialized stop-the-world mark pass — a dedicated GC cycle named goroutineLeakGC (in mgc.go), invoked by runtime/pprof’s writeGoroutineLeak. The release notes describe the criterion verbatim: “A leaked goroutine is a goroutine blocked on some concurrency primitive (channels, sync.Mutex, sync.Cond, etc) that cannot possibly become unblocked. The runtime detects leaked goroutines using the garbage collector: if a goroutine G is blocked on concurrency primitive P, and P is unreachable from any runnable goroutine or any goroutine that those could unblock, then P cannot be unblocked, so goroutine G can never wake up.” (Go 1.26 release notes). Mechanically this is a transitive closure:
- Seed. Partition every goroutine into a “could-run” set and a “candidate-leaked” set. The runtime does this in
allGsSnapshotSortedForGC(mgcmark.go): a goroutine is put in the candidate-leaked partition only if its status is_Gwaitingand itswaitreasonfalls in the contiguous concurrency-primitive band (the predicateinternalBlocked()). Everything else — running, runnable, blocked in a syscall (_Gsyscall), waiting in the network poller (IO wait), sleeping, blocked on internal-runtime semaphores, GC workers — is seeded as a root and treated as live, because any of those can resume on its own and go on to touch a primitive. - Scan the could-run goroutines’ stacks plus all globals, marking every concurrency primitive they reach (the standard GC object-graph walk).
findMaybeRunnableGoroutinesthen promotes any candidate-leaked goroutine whose primitive got marked: if a blocked goroutine’s channel/lock is now reachable, it could wake, so move it into the could-run set and scan its stack too, marking the primitives it reaches.- Repeat steps 2–3 to a fixpoint (
findGoroutineLeaksreturnsfalseand resumes marking as long as new could-run goroutines keep appearing). - Every goroutine still in the candidate-leaked partition is waiting on a primitive no could-run goroutine can reach.
findGoroutineLeakstransitions each from_Gwaitingto the new_Gleakedstatus and counts it. These are the leaks reported.
The precise wait-reasons that make a goroutine a candidate (per runtime2.go) are the contiguous block: chan receive (nil chan), chan send (nil chan), select (no cases), select, chan receive, chan send, sync.Cond.Wait, sync.Mutex.Lock, sync.RWMutex.RLock, sync.RWMutex.Lock, and sync.WaitGroup.Wait. A goroutine blocked for any other reason is presumed able to make progress and is never reported — which is exactly why syscall- and netpoll-blocked goroutines are roots, not leaks.
This reuses the GC’s object-graph walk: the same pointer-following machinery that determines liveness for sweeping determines primitive-reachability for leak detection. Crucially, the runtime semaphore (which backs sync.Mutex, sync.RWMutex, and sync.WaitGroup) was retrofitted to support this — sema.go’s queue function sets s.elem to the primitive’s address with the explicit comment “Storing this pointer so that we can trace the semaphore address from the blocked goroutine when checking for goroutine leaks.” Without that stored back-pointer, the detector could not tell which primitive a sema-blocked goroutine was waiting on; with it, findGoroutineLeaks can shade (mark) the exact mutex/waitgroup address (isSyncWait case) or channel address (isChanWait case) a leaked goroutine was parked on.
A deliberate carve-out lives in findGoroutineLeaks: the main goroutine blocked on select{} (wait reason select (no cases), a classic “park forever” idiom) is not reported, even though it is technically a partial deadlock. It is still treated as leaked during the analysis — so that any primitives it holds references to do not spuriously rescue other leaked goroutines — and then quietly reverted to _Gwaiting before the profile is emitted. The new goroutine status itself is defined in runtime2.go as _Gleaked (value 10), with the scan-in-progress variant _Gscanleaked (0x100a).
The release notes credit Vlad Saioc at Uber, with the underlying theory in Saioc et al., “Effective Detection of Goroutine Leaks” (PLDI/companion 2024/2025).
Cost and limitations
The detector adds no runtime overhead unless the profile is actively collected — there is no continuous tracking; the work happens only when you ask for the profile, at which point it costs one extra stop-the-world GC cycle. The Go team states the implementation is “production-ready, and is only considered an experiment for the purposes of collecting feedback on the API, specifically the choice to make it a new profile,” with the explicit aim of enabling it by default in Go 1.27 (Go 1.26 release notes).
The honest limitation, stated verbatim in the release notes: “Because this technique builds on reachability, the runtime may fail to identify leaks caused by blocking on concurrency primitives reachable through global variables or the local variables of runnable goroutines.” If a leaked goroutine’s channel is also referenced by a package-level global, the detector conservatively treats the channel as reachable and will not flag the leak — a false negative. It never produces false positives: anything it reports has been proven, by the reachability closure, to be unable to wake. (The release-notes wording was confirmed against the live page; the seed-set behaviour above — syscall/netpoll goroutines as roots, the internalBlocked() wait-reason band, the _Gleaked status, and the select{} main-goroutine carve-out — was confirmed by reading the 1.26 runtime source directly, resolving the prior uncertainty flag on this section.)
Code Examples
Collecting the experimental profile
//go:build goexperiment.goroutineleakprofile // requires GOEXPERIMENT build
package main
import (
"os"
"runtime/pprof"
)
func dumpLeaks() {
p := pprof.Lookup("goroutineleak") // (1) experimental profile type
if p == nil {
return // (2) nil if experiment not built in
}
f, _ := os.Create("leaks.pb.gz")
defer f.Close()
p.WriteTo(f, 0) // (3) debug=0 → protobuf for pprof tooling
}pprof.Lookup("goroutineleak")returns the profile only when the binary was built withGOEXPERIMENT=goroutineleakprofile; otherwise the profile is unregistered.- Guarding against
nilkeeps the same code compilable and runnable on a non-experiment build. WriteTo(f, 0)emits the protobuf format consumed bygo tool pprof;debug=1emits a human-readable count-profile of the leaked goroutines’ stacks. Note the special case inwriteGoroutineLeak:debug>=2deliberately falls back to a full goroutine-stack dump that includes non-leaked goroutines too (the same output as the ordinarygoroutineprofile atdebug=2), so use0or1to see only the leaked set. Either way, the call first triggersruntime_goroutineLeakGCso the leak set is freshly computed.
Build and serve:
GOEXPERIMENT=goroutineleakprofile go build -o app ./...
# with net/http/pprof imported, leaked goroutines appear at:
curl http://localhost:6060/debug/pprof/goroutineleak > leaks.pb.gz
go tool pprof -http=: leaks.pb.gzFixing the release-notes leak
The leak in processWorkItems is fixed by giving every worker a guaranteed receiver, or by sizing the channel so sends never block:
func processWorkItems(ws []workItem) ([]workResult, error) {
ch := make(chan result, len(ws)) // (1) buffered: every send completes
for _, w := range ws {
go func() { res, err := processWorkItem(w); ch <- result{res, err} }()
}
var results []workResult
var firstErr error
for range len(ws) {
r := <-ch // (2) drain ALL ws results, no early return
if r.err != nil && firstErr == nil {
firstErr = r.err
} else if r.err == nil {
results = append(results, r.res)
}
}
if firstErr != nil { return nil, firstErr }
return results, nil
}- A buffer of
len(ws)guaranteesch <- ...never blocks — every worker can deposit its result and return even if the consumer has stopped caring. - The loop now always performs
len(ws)receives, so no worker is ever left stranded mid-send. The alternative — passing a [[The Context Package|context]] andselect-ing onctx.Done()in the worker — lets workers abandon a slow send instead of always completing it.
Failure Modes and Common Misunderstandings
- “The GC will collect a blocked goroutine.” It will not. A goroutine is a GC root. Only a
return(orruntime.Goexit) frees it. A goroutine blocked forever is a permanent root pinning a permanent live set. - A leak is not a deadlock. A deadlock where all goroutines are blocked makes the runtime’s [[#the-runtime-deadlock-detector|
checkdead]] panic with “all goroutines are asleep - deadlock!” A leak is partial: most of the program runs fine while a subset of goroutines silently accumulates.checkdeadnever fires for a leak. - The 1.26 detector has false negatives, never false positives. If the channel is reachable from a global or a runnable goroutine’s locals, the detector stays silent even if the goroutine is morally leaked. It will never wrongly accuse a wakeable goroutine.
runtime.NumGoroutine()monotonically climbing is the symptom. Before 1.26, the standard diagnosis was watching this counter (or/debug/pprof/goroutine) trend upward under steady load. That still works as a symptom; the leak profile tells you which goroutines and why.time.Afterin aselectloop ties up a timer resource until it fires. A classic subtlety:select { case <-time.After(d): ... }in a hot loop creates a fresh timer each iteration whose resources stay live until it fires. As of Go 1.23 (for modules declaringgo 1.23.0+) unreferencedTimers andTickers become GC-eligible immediately, even withoutStop()— so this is far less dangerous than before, and the timer channel also became unbuffered in the same release (Go 1.23 release notes). Pre-1.23 code (or anything pinned viaGODEBUG=asynctimerchan=1) should still usetime.NewTimerandStop()to avoid the timer lingering until expiry.
Alternatives and When They Apply
Before Go 1.26, the standard tooling was the goroutine profile (pprof.Lookup("goroutine") / /debug/pprof/goroutine?debug=2), which dumps every goroutine’s stack. You then read the dump by hand looking for many goroutines parked at the same line — a manual, heuristic process with no notion of “leaked.” The 1.26 goroutineleak profile is strictly better when available: it isolates provably leaked goroutines instead of dumping all of them.
For tests, go.uber.org/goleak is the widely-used third-party detector: called in TestMain or via defer goleak.VerifyNone(t), it snapshots goroutines and fails the test if extras survive. It is heuristic (it diffs goroutine sets and ignores a known-good allowlist) and test-time only — complementary to, not replaced by, the runtime profile, which works in production.
Structurally, the real “alternative” is not leaking: scoping every goroutine’s lifetime to a [[The Context Package|context]], using [[errgroup and Structured Concurrency|errgroup]] so a parent waits for all children, and following clear channel-ownership rules so the closer is unambiguous. Detection tools find leaks; structured-concurrency patterns prevent them.
Production Notes
In long-lived services, goroutine leaks are one of the most common causes of slow memory growth that looks like a heap leak in a heap profile but is actually goroutine-stack accumulation. A telltale: the heap profile’s inuse_space grows but no single allocation site dominates — because the leaked memory is spread across thousands of goroutine stacks and their pinned closures. Cross-checking runtime.NumGoroutine() against request rate is the quick discriminator.
The 1.26 profile is built to be safe to wire into production behind the standard net/http/pprof endpoint, since collection cost is zero until invoked — though “invoked” still means a full stop-the-world GC, so it is a deliberate diagnostic action, not something to scrape every second. A practical operational pattern: scrape /debug/pprof/goroutineleak on a modest schedule and alert when the leaked-goroutine count crosses a threshold — turning a previously invisible, slow-burn failure into an observable metric. Until it defaults on in (targeted) Go 1.27, this requires a dedicated GOEXPERIMENT build, which is the main adoption friction.
Evidence for the “production-ready” claim is in the test suite itself: the runtime ships src/runtime/testdata/testgoroutineleakprofile/goker/, a corpus of leak reproductions distilled from real historical bugs in CockroachDB, etcd, gRPC-Go, Kubernetes, Docker/Moby, Hugo, and others (files named e.g. cockroach10214.go, etcd6708.go, grpc1275.go). The detector is validated against the shapes that actually bit large Go codebases, not just toy channels — which is the strongest available signal that the reachability technique generalizes beyond textbook examples.
See Also
- Goroutine Leak via Blocked Channel — the most common concrete leak shape, treated as its own gotcha
- Deadlocks in Go — related but distinct: total block +
checkdeadpanic vs. partial silent accumulation - The Context Package — cancellation as the primary leak-prevention mechanism
- errgroup and Structured Concurrency — bounding child-goroutine lifetimes to a parent
- Channel Direction and Ownership — clear ownership so the closer is unambiguous
- Runtime Semaphore — stores the
s.elemback-pointer the leak detector walks - Go Garbage Collector — whose reachability machinery the detector reuses
- pprof and Profiling — the
goroutineprofile, the pre-1.26 manual approach - Go 1.26 Release Notes — where the experimental profile is announced
- Go Internals MOC — parent map (Section 10, Concurrency Patterns and Bugs)